diff --git a/docs/security/evm-account-blacklist.md b/docs/security/evm-account-blacklist.md new file mode 100644 index 0000000000..82b1c75b7a --- /dev/null +++ b/docs/security/evm-account-blacklist.md @@ -0,0 +1,158 @@ +# EVM 账户黑名单:为什么框架层拦截之外还需要 EVM 内部检查 + +## 背景 + +7.20 uint64 溢出攻击(见 [evm-uint64-overflow-attack-analysis.md](./evm-uint64-overflow-attack-analysis.md))之后, +chain33 在框架层引入 `ForkAccountBlacklist` 与 `types.CheckTxBlockedAccount`, +在 mempool、出块验块、executor.checkTx 三处统一拦截涉及黑名单地址的交易。 + +本 PR(33cn/plugin#1304)在 EVM 插件内部再加了若干检查。评审中的核心问题是: +**框架层既然已经完整拦截,EVM 层的检查是冗余还是必要?** + +本文按「拦截从黑名单把资产打出」这一判定标准逐条回答,并说明代码取舍与回放约束。 + +## 判定标准 + +黑名单的目标是**冻结**攻击者已到手的资产,而不是阻止别人给它打钱。 +资产打入黑名单等同于加深冻结,本就不必拦; +真正要封死的是每一条能把黑名单地址名下余额转移出去的路径。 + +## 1. chain33 框架层覆盖了什么 + +三处入口最终都走 `types.checkTxBlockedAccountCore`(chain33 `types/account_blacklist.go`): + +| 层 | 位置 | fork 门控 | +|---|---|---| +| mempool 入口 | `system/mempool/check.go:76`、`eventprocess.go:216/339` | 无,随升级立即生效 | +| 出块 / 验块 | `system/consensus/base.go:606` `AddTxsToBlock` | 有 | +| executor.checkTx | `executor/execenv.go:173` | 有 | + +判定四个维度:`tx.From()`、`tx.GetTo()`、`tx.GetRealToAddr()`、 +EVM payload 中的 `ContractAddr` 与**恰好 20 字节**的 `Para`。 + +因为任何"打出"都必须由黑名单地址自己签名一笔交易,`from` 维度把主路径全部封死: + +| 案例 | 命中维度 | +|---|---| +| 黑名单发起 EVM 合约调用 / 纯转账 | `from` | +| 黑名单发起 coins / token 转账 | `from` | +| 黑名单提现 evm 子账户余额(`coins.Withdraw` 受益人恒为 `tx.From`) | `from` | +| 外部直接调用黑名单合约 | `to` / `ContractAddr` | +| 交易组任一笔命中 | 整组拒绝 | +| 黑名单为矿工地址(挖矿交易由矿工签名) | `from` | + +框架 checkTx 在 `execTx` 中先于驱动的 `CheckTx` / `Exec` 执行, +所以真实节点上命中名单的交易**根本进不到 EVM 驱动**。 + +## 2. 框架层看不见、只有 EVM 内部能拦的路径 + +框架只解析交易**信封**。合约跑起来之后内部 CALL 了谁、以谁的名义付款, +只存在于运行时栈和 calldata 里。以下每条路径在 `blacklist_gap_test.go` 都有对应用例, +用真实 coins 账户断言余额,并以 fork 关闭为对照证明路径确实是活的。 + +### B1. 黑名单合约被内部 CALL 唤醒后转出自身余额 + +``` +干净用户 U → 干净合约 B → CALL → 黑名单合约 A → A 把自己的余额转给 U +``` + +信封:`from=U, to=B`,框架放行。A 只出现在 B 的运行时栈上。 +**拦截点:`runtime/evm.go` `Call` 的 target 检查**(`checkBlockedAccount(evm, caller, addr)` 中的 `addr`)。 + +用例:`TestGapB1_BlockedContractWokenByInnerCall`。 + +### B2. token 预编译 `transfer(from, to, amount)` 第三方代打 —— 价值最高 + +`vm/runtime/token.go` 的 `transfer` 分支: + +```go +from := common.BytesToAddress(input[4:36]) // 取自 calldata,与 caller 无绑定 +... +evm.StateDB.TransferToToken(from.String(), to.String(), tokenName, amount) +``` + +任何 `manage` 名单内创建者部署的合约都能把 `from` 指定为黑名单地址。 +chain33 侧看到的 `Para` 是 100 字节 ABI calldata,`IsBlockedAccountRaw` 要求精确 20 字节,**完全看不见**。 +**拦截点:`statedb.go` `TransferToToken` 的 `from` 检查**,这条链路没有任何替代防线。 + +用例:`TestGapB2_TokenPrecompileThirdPartyFrom`(走真实 `tokenPrecompile.Run`)。 + +### B3. 黑名单合约 SELFDESTRUCT 把余额打给受益人 + +`opSuicide` → `AddBalance(beneficiary, CodeAddr, balance)` → `statedb.Transfer(CodeAddr → beneficiary)`。 +**拦截点:`statedb.go` `Transfer` 的 `sender` 检查**。 + +用例:`TestGapB3_SelfdestructBeneficiary`。 + +### B3b. DELEGATECALL 到黑名单代码执行 SELFDESTRUCT —— Call 检查覆盖不到 + +``` +干净用户 U → 干净合约 P → DELEGATECALL → 黑名单合约 A 的代码执行 SELFDESTRUCT +``` + +`runtime.DelegateCall` 没有黑名单检查(借代码在调用者上下文执行,一般不构成打出)。 +但 `opSuicide` 的**付款方取 `contract.CodeAddr`(= A)**,金额取 `contract.Address()`(= P)的余额: +攻击者给 P 充值 X,就能让 A 向受益人付出 X。 + +这条路径证明 `statedb.Transfer` 的 `sender` 检查不是对 `Call` 检查的重复, +而是唯一能拦下它的位置。 + +用例:`TestGapB3b_DelegatecallSelfdestructDrainsCodeAddr`。 + +### B4. 黑名单合约内部带 value 的 CALL + +与 B1 部分重叠;`Transfer` 的 `sender` 检查是最后一道闸,任何未来新增的调用路径都会落到这里。 + +用例:`TestGapB4_BlockedContractInnerValueCall`。 + +## 3. 谁都拦不住的盲区 + +**ERC20 合约 storage 记账的 `transferFrom(黑名单, x, n)`**:黑名单事先 `approve` 一个干净地址, +余额是 keccak storage 的读写,EVM 层没有任何钩子可挂。 +唯一解法是把该代币合约地址一并列入黑名单(冻结整个合约),或由代币合约自身加检查。 + +`ERC20.transfer(黑名单, x)` 则是打入,按本文标准不拦。 + +## 4. 代码取舍 + +### 实质防线(3 处,不可替代) + +| 位置 | 覆盖 | +|---|---| +| `runtime/evm.go` `Call` 的 **target** 检查 | B1 | +| `statedb.go` `TransferToToken` 的 **from** 检查 | B2 | +| `statedb.go` `Transfer` 的 **sender** 检查 | B3 / B3b / B4 | + +### 纵深防御(对真实交易冗余,源码中已逐处注明) + +| 位置 | 为什么对真实交易不生效 | +|---|---| +| `evm.go` `CheckTx` 的 from 检查 | 框架 checkTx 已在其之前拦截;且函数开头 `IsPara()` 直接返回,平行链上永不执行 | +| `exec.go` `innerExec` 的 from / contractAddr 检查 | `msg.From() == tx.From()`,框架 `from` / `ContractAddr` 维度已覆盖 | +| `statedb.go` `CanTransfer` 的 sender 检查 | 与 `Transfer` 重复;上层把 `false` 翻译成 `ErrNoBalance`,排查时以日志 `blocked account` 为准 | +| `runtime/evm.go` `Call` / `Create` 的 **caller** 检查 | 顶层 caller 即 `tx.From`;内部 caller 若是黑名单合约,必先通过某次 `Call` 的 target 检查 | +| `Transfer` / `TransferToToken` 的 **recipient** 检查 | 拦的是打入。保留以符合 chain33"禁止收发"的原始设计,删除不影响冻结目标 | + +`innerExec` 的 receiver 检查有一处实质覆盖:`isTransferOnly` 路径下 `Para` 超过 20 字节时, +chain33 的 `Para` 维度看不见,而 `BytesToAddress` 取后 20 字节仍能解析出黑名单地址 +(用例 `TestGap_ParaLengthAsymmetry`)。这只能打入,不紧急,但两侧行为已由测试固定。 + +## 5. 回放兼容约束 + +上表"纵深防御"的分支**已随本分支在主网执行过**。它们对真实交易开不了火, +但一旦升级窗口内出现过一笔"信封干净、内部碰到黑名单"的交易,其 receipt / 状态根就依赖这些分支。 +用去掉检查的二进制回放旧块会对不上。 + +因此撤除任何一处检查必须满足其一: + +- 新开 fork:旧高度仍走现逻辑,新高度跳过,老代码留在二进制里供回放;或 +- 先证明启用本分支且 `ForkAccountBlacklist` 生效至今,没有任何一笔交易的执行结果依赖这些分支 + (检索日志 `blocked account`,或新旧二进制回放同段区块比对 state hash)。 + +在此之前,"纵深防御"只作为评审结论记录在源码注释与本文中,不在本 PR 内删除。 + +## 6. fork 门控 + +statedb / runtime 两层的检查都以 `cfg.IsFork(height, ForkAccountBlacklist)` 门控。 +未到分叉高度时不得改变执行结果,否则与未升级节点分链(用例 `TestGap_ForkGateBlocksNothingBeforeHeight`)。 +mempool 入口无门控是故意的:它是节点本地行为、不进状态计算,随二进制升级立即止血。 diff --git a/plugin/dapp/evm/executor/attack_integration_test.go b/plugin/dapp/evm/executor/attack_integration_test.go index 8b30834971..9b020a9f4d 100644 --- a/plugin/dapp/evm/executor/attack_integration_test.go +++ b/plugin/dapp/evm/executor/attack_integration_test.go @@ -49,6 +49,8 @@ func newTestConfig(t *testing.T) *ctypes.Chain33Config { } cfg := ctypes.NewChain33Config(cfgStr) cfg.SetDappFork("evm", evmtypes.ForkEVMFixOverflow, 1000) + // 黑名单 system fork:与 ForkEVMFixOverflow 同步在高度 1000 生效(phase2) + cfg.SetFork(ctypes.ForkAccountBlacklist, 1000) return cfg } @@ -249,11 +251,50 @@ func TestWBTYOverflowAttackIntegration(t *testing.T) { // 关联地址 coins 转账(黑名单功能待集成) fundRole(t, exec, cfg, roleAttacker, 100*ctypes.DefaultCoinPrecision) fundRole(t, exec, cfg, roleAccomplice, 100*ctypes.DefaultCoinPrecision) - // 关联地址黑名单(待集成) + // 关联地址黑名单 t.Run("blacklist: all fund operations blocked", func(t *testing.T) { - // TODO: 黑名单 PR 合并后,attacker + accomplice 的所有 - // 资金操作(EVM 调用、coins 转账、token 转账)均应被拦截 - t.Skip("blacklist pending — roles attacker+accomplice marked") + // attacker + accomplice 的所有资金操作(EVM 调用、coins 转账)均应被拦截。 + // 使用 IsBlockedAccount 能识别的 ETH 地址形态(addrFromRole 返回 tx.From())。 + attackerAddr := addrFromRole(cfg, roleAttacker) + accompliceAddr := addrFromRole(cfg, roleAccomplice) + restore := ctypes.SetBlockedAccountsForTest([]string{attackerAddr, accompliceAddr}) + t.Cleanup(restore) + + // 真实节点上的闸门在 chain33 框架层:executor.checkTx 在调用驱动 Exec 之前 + // 就以 CheckTxBlockedAccount 按 from 维度拒绝,交易根本进不到 EVM 驱动。 + attackTx := makeCallTx(cfg, roleAttacker, contractAddr, depositInput, uint64(50*ctypes.DefaultCoinPrecision)) + if err := ctypes.CheckTxBlockedAccount(cfg, 1000, attackTx); err == nil { + t.Fatal("BUG: chain33 checkTx should reject blacklisted attacker EVM tx") + } + + // 直接调驱动 Exec 绕过了框架 checkTx,这条路径真实节点不存在; + // 此处只验证 EVM 层的纵深防御同样拒绝(结果与框架层一致,不产生额外状态)。 + _, err := exec.Exec(attackTx, 0) + if err == nil { + t.Fatal("BUG: EVM-layer defence in depth should also reject blacklisted attacker") + } + t.Logf("✓ blacklisted attacker EVM call REJECTED at both layers: %v", err) + + // coins 转账被拦截:accomplice 的 coins 交易在 mempool 层黑名单检查应失败。 + // EVM executor 只处理 EVM 交易;coins 交易的黑名单拦截在 chain33 通用层 + // CheckTxBlockedAccount(mempool.checkTx 调用),此处直接验证该层。 + coinsTx := makeCoinsTx(cfg, roleAccomplice, addrFromRole(cfg, roleLegitUser), 10*ctypes.DefaultCoinPrecision) + err = ctypes.CheckTxBlockedAccount(cfg, 1000, coinsTx) + if err == nil { + t.Fatal("BUG: blacklisted accomplice coins tx should fail CheckTxBlockedAccount") + } + t.Logf("✓ blacklisted accomplice coins tx REJECTED: %v", err) + + // 正常用户不受影响:legitUser 的 EVM deposit 在两层都放行 + legitTx := makeCallTx(cfg, roleLegitUser, contractAddr, depositInput, uint64(50*ctypes.DefaultCoinPrecision)) + if err := ctypes.CheckTxBlockedAccount(cfg, 1000, legitTx); err != nil { + t.Fatalf("legit user tx should pass chain33 checkTx: %v", err) + } + _, err = exec.Exec(legitTx, 0) + if err != nil { + t.Fatalf("legit user deposit should still work: %v", err) + } + t.Log("✓ legit user deposit still works") }) }) } diff --git a/plugin/dapp/evm/executor/evm.go b/plugin/dapp/evm/executor/evm.go index fac4f7f902..bfeae12e84 100644 --- a/plugin/dapp/evm/executor/evm.go +++ b/plugin/dapp/evm/executor/evm.go @@ -6,6 +6,7 @@ package executor import ( "bytes" + "encoding/hex" "fmt" "math/big" "os" @@ -217,6 +218,14 @@ func (evm *EVMExecutor) CheckTx(tx *types.Transaction, index int) error { return fmt.Errorf("tx empty") } + // 账户黑名单入口检查(fork 门控)。 + // 纵深防御:chain33 executor.checkTx 在调用本方法之前已按 from 维度拦截, + // 且本方法开头对平行链直接返回,此分支只在主链、且框架检查被绕过时才会命中。 + if err := checkEvmBlockedAccount(evm.GetAPI().GetConfig(), evm.GetHeight(), tx.From()); err != nil { + elog.Error("evm CheckTx blocked account", "txhash", hex.EncodeToString(tx.Hash()), "from", tx.From(), "err", err) + return err + } + return state.ProcessCheck(evm.GetMainHeight(), tx.Hash()) } diff --git a/plugin/dapp/evm/executor/exec.go b/plugin/dapp/evm/executor/exec.go index 1b4c62a24e..755594d666 100644 --- a/plugin/dapp/evm/executor/exec.go +++ b/plugin/dapp/evm/executor/exec.go @@ -102,6 +102,13 @@ func (evm *EVMExecutor) innerExec(msg *common.Message, txHash []byte, sigType in receiver = common.BytesToAddress(msg.Para()) } + // 账户黑名单:拦截收发双方命中名单的转账,保持 ExecPack 语义(返回 error 由上层包装)。 + // caller 已由 chain33 from 维度覆盖;receiver 在 Para 超过 20 字节时只有这里能拦(见 checkEvmBlockedAccount 注释)。 + if err := checkEvmBlockedAccount(cfg, evm.GetHeight(), caller.String(), receiver.String()); err != nil { + log.Error("innerExec blocked account transfer", "caller", caller.String(), "receiver", receiver.String(), "value", msg.Value(), "err", err) + return nil, err + } + if !evm.mStateDB.CanTransfer(caller.String(), msg.Value()) { log.Error("innerExec", "Not enough balance to be transferred from", caller.String(), "amout", msg.Value()) return nil, types.ErrNoBalance @@ -141,6 +148,14 @@ func (evm *EVMExecutor) innerExec(msg *common.Message, txHash []byte, sigType in // evm // 状态机中设置当前交易状态 evm.mStateDB.Prepare(common.BytesToHash(txHash), index) + + // 账户黑名单:合约调用/创建时拦截发送方与目标合约地址命中名单的情况。 + // 两个维度均已由 chain33 的 from / ContractAddr 检查覆盖,此处为纵深防御。 + if err := checkEvmBlockedAccount(cfg, evm.GetHeight(), msg.From().String(), contractAddrStr); err != nil { + log.Error("innerExec blocked account call/create", "from", msg.From().String(), "contractAddr", contractAddrStr, "err", err) + return nil, err + } + if isCreate { ret, snapshot, leftOverGas, vmerr = env.Create(runtime.AccountRef(msg.From()), contractAddr, msg.Data(), context.GasLimit, execName, msg.Alias(), msg.Value()) } else { @@ -425,6 +440,33 @@ func getCaller(tx *types.Transaction) common.Address { return *common.StringToAddress(tx.From()) } +// checkEvmBlockedAccount 在 EVM 执行内部拦截命中黑名单的地址(发送方/接收方/合约地址)。 +// 与 types.CheckTxBlockedAccount 共用同一黑名单,但按地址维度检查(此时地址已解析为字符串)。 +// 这里走 fork 门控:仅在 ForkAccountBlacklist 高度后生效。 +// 返回 error 后由调用方按 ExecPack 语义处理(保持 revert + 扣费),不升级为 ExecErr。 +// +// 分层说明(详见 docs/security/evm-account-blacklist.md): +// 交易信封上的 from / to / ContractAddr / 20 字节 Para 已由 chain33 在 +// mempool、出块验块、executor.checkTx 三层拦截,真实节点上命中名单的交易进不到本驱动。 +// 因此 innerExec 里对 msg.From / receiver / contractAddr 的检查对真实交易是纵深防御: +// 唯一的实质覆盖是 isTransferOnly 路径的 receiver —— Para 超过 20 字节时 +// chain33 的 Para 维度看不见,而 BytesToAddress 取后 20 字节仍能解析出黑名单地址。 +// 这些分支已随本分支在主网执行过,撤除需新开 fork 或先审计历史,不可直接删除。 +func checkEvmBlockedAccount(cfg *types.Chain33Config, height int64, addrs ...string) error { + if cfg == nil || !cfg.IsFork(height, types.ForkAccountBlacklist) { + return nil + } + for _, addr := range addrs { + if addr == "" { + continue + } + if types.IsBlockedAccount(addr) { + return fmt.Errorf("%w: %s", types.ErrBlockedAccount, addr) + } + } + return nil +} + // 从交易信息中获取交易目标地址,在创建合约交易中,此地址为空 func getReceiver(action *evmtypes.EVMContractAction, mixAddressFork bool) *common.Address { if action.ContractAddr == "" { diff --git a/plugin/dapp/evm/executor/vm/runtime/account_blacklist_test.go b/plugin/dapp/evm/executor/vm/runtime/account_blacklist_test.go new file mode 100644 index 0000000000..4909326ba6 --- /dev/null +++ b/plugin/dapp/evm/executor/vm/runtime/account_blacklist_test.go @@ -0,0 +1,84 @@ +// Copyright Fuzamei Corp. 2018 All Rights Reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package runtime + +import ( + "errors" + "math/big" + "testing" + + "github.com/33cn/chain33/types" + "github.com/33cn/plugin/plugin/dapp/evm/executor/vm/common" + "github.com/33cn/plugin/plugin/dapp/evm/executor/vm/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const blockedRuntimeAddr = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0" + +func newBlockedEVM(t *testing.T, blockedAddrs []string) *EVM { + t.Helper() + cfg := types.NewChain33Config(types.GetDefaultCfgstring()) + // local 标题下 SetAllFork(0),ForkAccountBlacklist 从高度 0 启用 + restore := types.SetBlockedAccountsForTest(blockedAddrs) + t.Cleanup(restore) + ctx := Context{BlockNumber: big.NewInt(1)} + return NewEVM(ctx, &state.MemoryStateDB{}, Config{}, cfg) +} + +func TestCheckBlockedAccount(t *testing.T) { + blocked := common.BytesToAddress(common.FromHex(blockedRuntimeAddr)) + normal := common.BytesToAddress(common.FromHex("0x0000000000000000000000000000000000000001")) + + t.Run("hit caller", func(t *testing.T) { + evm := newBlockedEVM(t, []string{blockedRuntimeAddr}) + err := checkBlockedAccount(evm, blocked, normal) + require.Error(t, err) + assert.True(t, errors.Is(err, types.ErrBlockedAccount)) + }) + + t.Run("hit target", func(t *testing.T) { + evm := newBlockedEVM(t, []string{blockedRuntimeAddr}) + err := checkBlockedAccount(evm, normal, blocked) + require.Error(t, err) + assert.True(t, errors.Is(err, types.ErrBlockedAccount)) + }) + + t.Run("normal pass", func(t *testing.T) { + evm := newBlockedEVM(t, []string{blockedRuntimeAddr}) + assert.NoError(t, checkBlockedAccount(evm, normal, normal)) + }) + + t.Run("empty blocklist pass", func(t *testing.T) { + cfg := types.NewChain33Config(types.GetDefaultCfgstring()) + restore := types.SetBlockedAccountsForTest([]string{}) + defer restore() + evm := NewEVM(Context{BlockNumber: big.NewInt(1)}, &state.MemoryStateDB{}, Config{}, cfg) + assert.NoError(t, checkBlockedAccount(evm, blocked, blocked)) + }) +} + +// TestCallBlockedAccount 验证 EVM.Call 在黑名单地址下返回 error(触发上层 revert) +func TestCallBlockedAccount(t *testing.T) { + evm := newBlockedEVM(t, []string{blockedRuntimeAddr}) + caller := AccountRef(common.BytesToAddress(common.FromHex(blockedRuntimeAddr))) + target := common.BytesToAddress(common.FromHex("0x0000000000000000000000000000000000000001")) + + _, _, _, err := evm.Call(caller, target, nil, 100000, 0) + require.Error(t, err) + assert.True(t, errors.Is(err, types.ErrBlockedAccount)) +} + +// TestCreateBlockedAccount 验证 EVM.Create 在黑名单地址下返回 error(触发上层 revert)。 +// 与 TestCallBlockedAccount 对称,覆盖 Create 路径的 checkBlockedAccount 分支。 +func TestCreateBlockedAccount(t *testing.T) { + evm := newBlockedEVM(t, []string{blockedRuntimeAddr}) + caller := AccountRef(common.BytesToAddress(common.FromHex(blockedRuntimeAddr))) + contractAddr := common.BytesToAddress(common.FromHex("0x0000000000000000000000000000000000000002")) + + _, _, _, err := evm.Create(caller, contractAddr, nil, 100000, "evm", "test", 0) + require.Error(t, err) + assert.True(t, errors.Is(err, types.ErrBlockedAccount)) +} diff --git a/plugin/dapp/evm/executor/vm/runtime/blacklist_gap_test.go b/plugin/dapp/evm/executor/vm/runtime/blacklist_gap_test.go new file mode 100644 index 0000000000..435f73a9b8 --- /dev/null +++ b/plugin/dapp/evm/executor/vm/runtime/blacklist_gap_test.go @@ -0,0 +1,347 @@ +// Copyright Fuzamei Corp. 2018 All Rights Reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package runtime + +// 本文件验证的是 chain33 框架层(types.CheckTxBlockedAccount)看不见、 +// 只能由 EVM 内部检查拦截的资产打出路径。每个用例都: +// 1. 用真实 MemoryStateDB + coins 账户,断言余额而不是只断言 error; +// 2. 先证明交易信封上的地址全部干净(chain33 放行),再证明 EVM 层拦下; +// 3. 以 fork 关闭作为对照,证明放行时资产确实会被打出,测试不是空转。 +// +// 场景编号对应 docs/security/evm-account-blacklist.md 的 B1-B4。 + +import ( + "math/big" + "strings" + "testing" + + "github.com/33cn/chain33/account" + apimock "github.com/33cn/chain33/client/mocks" + ctypes "github.com/33cn/chain33/types" + "github.com/33cn/chain33/util" + vmcommon "github.com/33cn/plugin/plugin/dapp/evm/executor/vm/common" + "github.com/33cn/plugin/plugin/dapp/evm/executor/vm/state" + "github.com/stretchr/testify/require" +) + +const gapTestHeight = int64(700000) + +type gapEnv struct { + t *testing.T + cfg *ctypes.Chain33Config + mdb *state.MemoryStateDB + coins *account.DB + evm *EVM +} + +// newGapEnv 构建带真实 coins 账户的 EVM 运行环境。 +// 默认 cfgstring 的 Title="local" 会把所有 fork(含 ForkAccountBlacklist)置为 0, +// 需要关闭 fork 做对照时由调用方显式 SetFork。 +// 追加 ethMapFromExecutor="coins" 使 statedb 直接读写 coins 主账户, +// 否则余额走 evm 子账户(LoadExecAccount),fund/balance 会与实际转账路径不一致。 +func newGapEnv(t *testing.T, blocked []string) *gapEnv { + t.Helper() + cfgStr := ctypes.GetDefaultCfgstring() + if !strings.Contains(cfgStr, "[exec.sub.evm]") { + cfgStr += "\n[exec.sub.evm]\nethMapFromExecutor=\"coins\"\nethMapFromSymbol=\"bty\"\n" + } + cfg := ctypes.NewChain33Config(cfgStr) + api := new(apimock.QueueProtocolAPI) + api.On("GetConfig").Return(cfg) + + dbDir, stateDB, localDB := util.CreateTestDB() + t.Cleanup(func() { util.CloseTestDB(dbDir, stateDB) }) + + coins, err := account.NewAccountDB(cfg, "coins", cfg.GetCoinSymbol(), stateDB) + require.NoError(t, err) + + mdb := state.NewMemoryStateDB(stateDB, localDB, coins, gapTestHeight, api) + mdb.Prepare(vmcommon.BytesToHash([]byte("blacklist-gap")), 0) + + restore := ctypes.SetBlockedAccountsForTest(blocked) + t.Cleanup(restore) + + env := &gapEnv{t: t, cfg: cfg, mdb: mdb, coins: coins} + env.evm = NewEVM(Context{ + CanTransfer: func(db state.EVMStateDB, s vmcommon.Address, v uint64) bool { return db.CanTransfer(s.String(), v) }, + Transfer: func(db state.EVMStateDB, s, r vmcommon.Address, v uint64) bool { + return db.Transfer(s.String(), r.String(), v) + }, + GetHash: func(uint64) vmcommon.Hash { return vmcommon.Hash{} }, + BlockNumber: big.NewInt(gapTestHeight), + }, mdb, Config{}, cfg) + return env +} + +func (e *gapEnv) fund(addr vmcommon.Address, amount int64) { + acc := e.coins.LoadAccount(addr.String()) + acc.Balance = amount + e.coins.SaveAccount(acc) +} + +func (e *gapEnv) balance(addr vmcommon.Address) int64 { + return e.coins.LoadAccount(addr.String()).Balance +} + +func (e *gapEnv) deploy(addr, creator vmcommon.Address, code []byte) { + e.mdb.CreateAccount(addr.String(), creator.String(), "evm."+addr.String(), addr.String()) + e.mdb.SetCode(addr.String(), code) +} + +// assertEnvelopeClean 证明交易信封上的地址 chain33 都放行: +// 这就是框架层 CheckTxBlockedAccount 对这类内部交易"看不见"的原因。 +func (e *gapEnv) assertEnvelopeClean(addrs ...vmcommon.Address) { + e.t.Helper() + for _, a := range addrs { + require.False(e.t, ctypes.IsBlockedAccount(a.String()), "envelope address %s must be clean", a) + } +} + +func addr(b byte) vmcommon.Address { return vmcommon.BytesToAddress([]byte{b}) } + +// buildValueCallCode 生成 "CALL 携带 value" 的合约代码(inputs 全 0)。 +func buildValueCallCode(callee vmcommon.Address, value uint64) []byte { + code := []byte{ + 0x60, 0x00, // retSize + 0x60, 0x00, // retOffset + 0x60, 0x00, // inSize + 0x60, 0x00, // inOffset + } + code = append(code, pushUint64(value)...) + code = append(code, 0x73) // PUSH20 + code = append(code, callee.Bytes()...) + code = append(code, + 0x61, 0xff, 0xff, // gas + 0xf1, // CALL + 0x00, // STOP + ) + return code +} + +// buildDelegateCallCode 生成 "DELEGATECALL " 的合约代码。 +func buildDelegateCallCode(callee vmcommon.Address) []byte { + code := []byte{ + 0x60, 0x00, // retSize + 0x60, 0x00, // retOffset + 0x60, 0x00, // inSize + 0x60, 0x00, // inOffset + 0x73, // PUSH20 + } + code = append(code, callee.Bytes()...) + code = append(code, + 0x61, 0xff, 0xff, // gas + 0xf4, // DELEGATECALL + 0x00, // STOP + ) + return code +} + +// buildSelfdestructCode 生成 "SELFDESTRUCT " 的合约代码。 +func buildSelfdestructCode(beneficiary vmcommon.Address) []byte { + code := []byte{0x73} // PUSH20 + code = append(code, beneficiary.Bytes()...) + return append(code, 0xff) // SELFDESTRUCT +} + +func pushUint64(v uint64) []byte { + b := new(big.Int).SetUint64(v).Bytes() + if len(b) == 0 { + b = []byte{0} + } + return append([]byte{0x60 + byte(len(b)-1)}, b...) +} + +// TestGapB1_BlockedContractWokenByInnerCall 场景 B1: +// +// 干净用户 U → 干净合约 B → CALL → 黑名单合约 A → A 向 U 转出自身余额 +// +// chain33 只看到 from=U、to=B,A 只出现在 B 的运行时栈上。 +// evm.Call 的 target 检查是唯一能阻止 A 被唤醒的位置。 +func TestGapB1_BlockedContractWokenByInnerCall(t *testing.T) { + user, relay, blockedC := addr(0x11), addr(0xb1), addr(0xa1) + env := newGapEnv(t, []string{blockedC.String()}) + env.assertEnvelopeClean(user, relay) + + const stash = int64(1_000_000) + env.fund(blockedC, stash) + env.deploy(relay, user, buildValueCallCode(blockedC, 0)) + env.deploy(blockedC, user, buildValueCallCode(user, uint64(stash))) + + _, _, _, err := env.evm.Call(AccountRef(user), relay, nil, 5_000_000, 0) + require.NoError(t, err, "outer call is legal; inner failure is a revert, not a tx error") + require.Equal(t, stash, env.balance(blockedC), "blocked contract must keep its balance") + require.Zero(t, env.balance(user), "user must not receive blocked funds") + + // 对照:fork 关闭时同一条链路会把钱打出去,证明上面的断言不是空转。 + env.cfg.SetFork(ctypes.ForkAccountBlacklist, ctypes.MaxHeight) + _, _, _, err = env.evm.Call(AccountRef(user), relay, nil, 5_000_000, 0) + require.NoError(t, err) + require.Zero(t, env.balance(blockedC), "with fork off the funds must leave (proves the path is live)") + require.Equal(t, stash, env.balance(user)) +} + +// TestGapB2_TokenPrecompileThirdPartyFrom 场景 B2: +// token 预编译 transfer(from,to,amount) 的 from 取自 calldata,与 caller 无绑定, +// 第三方合约可任意指定 from=黑名单。chain33 侧 Para 长度 100 字节, +// IsBlockedAccountRaw 要求精确 20 字节,完全看不见。 +// statedb.TransferToToken 的 from 检查是这条链路的唯一防线。 +func TestGapB2_TokenPrecompileThirdPartyFrom(t *testing.T) { + victim, receiver, manager, caller := addr(0xa2), addr(0x22), addr(0x33), addr(0xc2) + env := newGapEnv(t, []string{victim.String()}) + env.assertEnvelopeClean(receiver, manager, caller) + + // 由 manage 名单内的创建者部署的第三方合约作为 precompile 的 caller + env.deploy(caller, manager, nil) + precompile := vmcommon.BytesToAddress(vmcommon.FromHex(TokenPrecompileAddr)) + saved, had := CustomizePrecompiledContracts[precompile.ToHash160()] + CustomizePrecompiledContracts[precompile.ToHash160()] = NewTokenPrecompile(&TokenContract{SuperManager: []string{manager.String()}}) + t.Cleanup(func() { + if had { + CustomizePrecompiledContracts[precompile.ToHash160()] = saved + } else { + delete(CustomizePrecompiledContracts, precompile.ToHash160()) + } + }) + + // transfer(address,address,uint256) 的 ABI 编码 = 4 + 32*3 字节,from 落在 [4:36] 的后 20 字节 + calldata := vmcommon.FromHex("0x" + transfer) + calldata = append(calldata, make([]byte, 32*3)...) + copy(calldata[4+12:], victim.Bytes()) + copy(calldata[36+12:], receiver.Bytes()) + calldata[len(calldata)-1] = 100 + require.False(t, ctypes.IsBlockedAccountRaw(calldata), "chain33 Para check must be blind to 100-byte calldata") + + ret, _, err := RunStateFulPrecompiledContract(env.evm, AccountRef(caller), CustomizePrecompiledContracts[precompile.ToHash160()], calldata, 100000) + require.ErrorIs(t, err, ctypes.ErrBlockedAccount, "ret=%s", ret) + + // 检查一旦被移除,同一调用会落到 tokenStatus 的 ErrNotFound(本测试不注册 token), + // 不再是 ErrBlockedAccount —— 用它区分"被黑名单拦下"和"因别的原因失败"。 + env.cfg.SetFork(ctypes.ForkAccountBlacklist, ctypes.MaxHeight) + _, _, err = RunStateFulPrecompiledContract(env.evm, AccountRef(caller), CustomizePrecompiledContracts[precompile.ToHash160()], calldata, 100000) + require.Error(t, err) + require.NotErrorIs(t, err, ctypes.ErrBlockedAccount) + require.ErrorIs(t, err, ctypes.ErrNotFound, "with fork off the call reaches tokenStatus, i.e. past the blacklist gate") +} + +// TestGapB3_SelfdestructBeneficiary 场景 B3: +// 黑名单合约 SELFDESTRUCT,把余额打给受益人。 +// opSuicide → AddBalance → statedb.Transfer(sender=合约),Transfer 的 sender 检查拦住。 +// 这里直接以黑名单合约为 Call 目标会先被 Call 的 target 检查拦下, +// 为了单独验证 Transfer 这道闸,用 DELEGATECALL 绕过 Call 检查(见 B3b), +// 本用例只固定"经由 CALL 唤醒"这条完整链路的最终结果。 +func TestGapB3_SelfdestructBeneficiary(t *testing.T) { + user, relay, relay2, blockedC := addr(0x13), addr(0xb3), addr(0xb6), addr(0xa3) + env := newGapEnv(t, []string{blockedC.String()}) + env.assertEnvelopeClean(user, relay, relay2) + + const stash = int64(500_000) + env.fund(blockedC, stash) + env.deploy(relay, user, buildValueCallCode(blockedC, 0)) + env.deploy(relay2, user, buildValueCallCode(blockedC, 0)) + env.deploy(blockedC, user, buildSelfdestructCode(user)) + + _, _, _, err := env.evm.Call(AccountRef(user), relay, nil, 5_000_000, 0) + require.NoError(t, err) + require.Equal(t, stash, env.balance(blockedC)) + require.Zero(t, env.balance(user)) + + env.cfg.SetFork(ctypes.ForkAccountBlacklist, ctypes.MaxHeight) + _, _, _, err = env.evm.Call(AccountRef(user), relay2, nil, 5_000_000, 0) + require.NoError(t, err) + require.Zero(t, env.balance(blockedC), "with fork off SELFDESTRUCT pays the beneficiary") + require.Equal(t, stash, env.balance(user)) +} + +// TestGapB3b_DelegatecallSelfdestructDrainsCodeAddr 场景 B3 的变体, +// 也是 Call/Create 检查覆盖不到、只有 statedb.Transfer 能拦的路径: +// +// 干净用户 U → 干净合约 P → DELEGATECALL → 黑名单合约 A 的代码执行 SELFDESTRUCT +// +// evm.DelegateCall 没有黑名单检查(DELEGATECALL 借代码在调用者上下文执行, +// 通常不构成打出)。但 opSuicide 的付款方取 contract.CodeAddr(= A), +// 金额取 contract.Address()(= P)的余额:攻击者给 P 充值 X,即可让 A 向受益人付出 X。 +// 这使 statedb.Transfer 的 sender 检查成为真正的最后一道闸,而非冗余。 +func TestGapB3b_DelegatecallSelfdestructDrainsCodeAddr(t *testing.T) { + user, proxy, proxy2, blockedC := addr(0x14), addr(0xb4), addr(0xb5), addr(0xa4) + env := newGapEnv(t, []string{blockedC.String()}) + env.assertEnvelopeClean(user, proxy, proxy2) + + const stash = int64(300_000) + env.fund(blockedC, stash) + env.fund(proxy, stash) + env.fund(proxy2, stash) + env.deploy(proxy, user, buildDelegateCallCode(blockedC)) + env.deploy(proxy2, user, buildDelegateCallCode(blockedC)) + env.deploy(blockedC, user, buildSelfdestructCode(user)) + + _, _, _, err := env.evm.Call(AccountRef(user), proxy, nil, 5_000_000, 0) + require.NoError(t, err) + require.Equal(t, stash, env.balance(blockedC), "delegatecall+selfdestruct must not drain the blocked code address") + require.Zero(t, env.balance(user)) + + // 对照:fork 关闭时 A 的余额确实经由 CodeAddr 被打出(proxy 已自毁,换 proxy2 触发)。 + env.cfg.SetFork(ctypes.ForkAccountBlacklist, ctypes.MaxHeight) + _, _, _, err = env.evm.Call(AccountRef(user), proxy2, nil, 5_000_000, 0) + require.NoError(t, err) + require.Zero(t, env.balance(blockedC), "with fork off, opSuicide pays from CodeAddr (=A)") + require.Equal(t, stash, env.balance(user)) + require.Equal(t, stash, env.balance(proxy2), "the proxy itself is untouched: the payer is A, not P") +} + +// TestGapB4_BlockedContractInnerValueCall 场景 B4: +// 黑名单合约 A 被 Call 拦下之前的"最后一道闸"——直接以 A 为 caller 发起带 value 的内部转账, +// 模拟任何绕过 Call target 检查(例如未来新增的调用路径)的情形, +// statedb.Transfer 的 sender 检查仍然拒绝。 +func TestGapB4_BlockedContractInnerValueCall(t *testing.T) { + user, blockedC := addr(0x15), addr(0xa5) + env := newGapEnv(t, []string{blockedC.String()}) + + const stash = int64(200_000) + env.fund(blockedC, stash) + + require.False(t, env.mdb.Transfer(blockedC.String(), user.String(), uint64(stash))) + require.Equal(t, stash, env.balance(blockedC)) + require.Zero(t, env.balance(user)) + + // Transfer 对"打入"也拒绝(chain33 原始设计:禁止收发),保留但不作为主要目标。 + env.fund(user, stash) + require.False(t, env.mdb.Transfer(user.String(), blockedC.String(), uint64(stash))) + require.Equal(t, stash, env.balance(user)) +} + +// TestGap_ForkGateBlocksNothingBeforeHeight 验证 fork 门控: +// 未到 ForkAccountBlacklist 高度时,statedb / runtime 两层都不得改变执行结果, +// 否则会与未升级节点分链。 +func TestGap_ForkGateBlocksNothingBeforeHeight(t *testing.T) { + user, blockedC := addr(0x16), addr(0xa6) + env := newGapEnv(t, []string{blockedC.String()}) + env.cfg.SetFork(ctypes.ForkAccountBlacklist, gapTestHeight+1) + + const stash = int64(100_000) + env.fund(blockedC, stash) + require.True(t, env.mdb.CanTransfer(blockedC.String(), uint64(stash))) + require.True(t, env.mdb.Transfer(blockedC.String(), user.String(), uint64(stash))) + require.Equal(t, stash, env.balance(user)) + + require.NoError(t, checkBlockedAccount(env.evm, blockedC, user)) +} + +// TestGap_ParaLengthAsymmetry 记录 chain33 Para 维度与 EVM 地址解析之间的不对称: +// vmcommon.Address.SetBytes 对超长输入取后 20 字节,chain33 IsBlockedAccountRaw 要求精确 20 字节。 +// 用 32 字节左填充的 Para 可绕过 chain33 的 Para 维度,但只能"打入"、不能"打出", +// 由 innerExec 的 receiver 检查兜底。此处固定该行为,防止两侧被无意改成不一致。 +func TestGap_ParaLengthAsymmetry(t *testing.T) { + blockedC := addr(0xa7) + restore := ctypes.SetBlockedAccountsForTest([]string{blockedC.String()}) + t.Cleanup(restore) + + padded := make([]byte, 32) + copy(padded[12:], blockedC.Bytes()) + + require.False(t, ctypes.IsBlockedAccountRaw(padded), "chain33 is blind to 32-byte padded Para") + require.Equal(t, blockedC, vmcommon.BytesToAddress(padded), "EVM resolves the same bytes to the blocked address") + require.True(t, ctypes.IsBlockedAccount(vmcommon.BytesToAddress(padded).String()), + "so the EVM-side receiver check catches what chain33 misses") +} diff --git a/plugin/dapp/evm/executor/vm/runtime/evm.go b/plugin/dapp/evm/executor/vm/runtime/evm.go index 9e54b9a4b4..8323e2456d 100644 --- a/plugin/dapp/evm/executor/vm/runtime/evm.go +++ b/plugin/dapp/evm/executor/vm/runtime/evm.go @@ -5,6 +5,7 @@ package runtime import ( + "fmt" "math/big" "sync/atomic" @@ -19,6 +20,32 @@ import ( evmtypes "github.com/33cn/plugin/plugin/dapp/evm/types" ) +// checkBlockedAccount 合约内部调用/创建的黑名单拦截(fork 门控)。 +// 命中返回包装后的 types.ErrBlockedAccount,由调用方返回 error 触发 RevertToSnapshot。 +// 与 executor 层共用 types 黑名单,按 EVM 地址(0x 字符串)检查。 +// +// 这是 chain33 框架层看不见的一层:框架只解析交易信封(from/to/ContractAddr/Para), +// 合约运行后内部 CALL 了谁只存在于运行时栈上。Call 的 target 检查是阻止 +// "干净用户 → 干净合约 → CALL → 黑名单合约 → 转出自身余额" 的唯一位置 +// (docs/security/evm-account-blacklist.md 场景 B1,blacklist_gap_test.go 有对应用例)。 +// caller 维度则是纵深防御:顶层 caller 即 tx.From 已被框架拦截, +// 内部 caller 若是黑名单合约,它必须先通过某次 Call 的 target 检查才能执行到这里。 +// CallCode / DelegateCall / StaticCall 不做检查:它们借目标代码在调用者上下文执行, +// 动的是调用者的存储和余额,不构成从黑名单打出的路径; +// 唯一例外(DELEGATECALL 到黑名单代码执行 SELFDESTRUCT)由 statedb.Transfer 兜底。 +func checkBlockedAccount(evm *EVM, addrs ...common.Address) error { + cfg := evm.cfg + if cfg == nil || evm.BlockNumber == nil || !cfg.IsFork(evm.BlockNumber.Int64(), types.ForkAccountBlacklist) { + return nil + } + for _, addr := range addrs { + if types.IsBlockedAccount(addr.String()) { + return fmt.Errorf("%w: %s", types.ErrBlockedAccount, addr.String()) + } + } + return nil +} + type ( // CanTransferFunc 检查制定账户是否有足够的金额进行转账 CanTransferFunc func(state.EVMStateDB, common.Address, uint64) bool @@ -200,6 +227,13 @@ func (evm *EVM) Call(caller ContractRef, addr common.Address, input []byte, gas return nil, -1, gas, err } + // 账户黑名单:合约内部调用拦截,返回 error 触发 RevertToSnapshot(ExecPack 语义)。 + // addr(被调目标)是本检查的实质价值所在,caller 为纵深防御,见 checkBlockedAccount 注释。 + if berr := checkBlockedAccount(evm, caller.Address(), addr); berr != nil { + log.Error("Call blocked account", "caller", caller.Address().String(), "addr", addr.String(), "err", berr) + return nil, -1, gas, berr + } + p, sp, isPrecompile := evm.precompile(addr) if !evm.StateDB.Exist(addr.String()) { // 合约地址在自定义合约和预编译合约中都不存在时,可能为外部账户 @@ -490,6 +524,14 @@ func (evm *EVM) Create(caller ContractRef, contractAddr common.Address, code []b return nil, -1, gas, err } + // 账户黑名单:合约创建拦截,返回 error 触发 RevertToSnapshot(ExecPack 语义)。 + // 顶层 Create 的 caller / contractAddr 已由 chain33 覆盖;此处主要拦 CREATE/CREATE2 + // 派生出的合约地址恰好命中名单的情况(概率意义上的兜底)。 + if berr := checkBlockedAccount(evm, caller.Address(), contractAddr); berr != nil { + log.Error("Create blocked account", "caller", caller.Address().String(), "contractAddr", contractAddr.String(), "err", berr) + return nil, -1, gas, berr + } + // 向合约地址转账 cfg := evm.StateDB.GetConfig() if cfg.IsDappFork(evm.BlockNumber.Int64(), "evm", evmtypes.ForkEVMFixOverflow) { diff --git a/plugin/dapp/evm/executor/vm/state/statedb.go b/plugin/dapp/evm/executor/vm/state/statedb.go index 9df4d0adb2..9490e43b93 100644 --- a/plugin/dapp/evm/executor/vm/state/statedb.go +++ b/plugin/dapp/evm/executor/vm/state/statedb.go @@ -97,6 +97,34 @@ func NewMemoryStateDB(StateDB db.KV, LocalDB db.KVDB, CoinsAccount *account.DB, return mdb } +// isBlockedAccount 账户黑名单兜底判定,带 ForkAccountBlacklist 门控。 +// statedb 的转账结果直接进入状态计算,未到分叉高度时不得改变执行结果,否则会与未升级节点分链。 +// +// 这一层是资产打出的最后一道闸,覆盖 chain33 与 runtime.Call 都看不见的两条路径 +// (docs/security/evm-account-blacklist.md 场景 B2 / B3,blacklist_gap_test.go 有对应用例): +// - TransferToToken 的 from:token 预编译的 from 取自 calldata,与 caller 无绑定, +// 第三方合约可指定 from=黑名单;calldata 长度 100 字节,chain33 的 Para 维度看不见。 +// - Transfer 的 sender:SELFDESTRUCT 经 AddBalance 以合约为付款方转账, +// 且 opSuicide 的付款方取 contract.CodeAddr,DELEGATECALL 到黑名单代码时 +// runtime 层没有任何检查,只有这里能拦。 +// +// recipient 维度拦的是"打入"(等同冻结),保留以符合 chain33"禁止收发"的原始设计。 +func (mdb *MemoryStateDB) isBlockedAccount(addrs ...string) bool { + if mdb.api == nil { + return false + } + cfg := mdb.api.GetConfig() + if cfg == nil || !cfg.IsFork(mdb.blockHeight, types.ForkAccountBlacklist) { + return false + } + for _, addr := range addrs { + if types.IsBlockedAccount(addr) { + return true + } + } + return false +} + // Prepare 每一个交易执行之前调用此方法,设置此交易的上下文信息 // 目前的上下文中包含交易哈希以及交易在区块中的序号 func (mdb *MemoryStateDB) Prepare(txHash common.Hash, txIndex int) { @@ -444,6 +472,14 @@ func (mdb *MemoryStateDB) GetChangedData(version int) (kvSet []*types.KeyValue, // CanTransfer 借助coins执行器进行转账相关操作 func (mdb *MemoryStateDB) CanTransfer(sender string, amount uint64) bool { + // 账户黑名单兜底:命中名单的发送方一律视为不可转账。 + // 与 Transfer 的 sender 检查重复;注意上层会把 false 翻译成 ErrNoBalance + // (exec.go innerExec / runtime.preCheck),排查时以日志中的 "blocked account" 为准。 + // 该分支已随本分支在主网执行过,撤除需新开 fork,不可直接删除。 + if mdb.isBlockedAccount(sender) { + log15.Error("CanTransfer blocked account", "sender", sender, "height", mdb.blockHeight) + return false + } var senderAcc *types.Account conf := types.ConfSub(mdb.api.GetConfig(), evmtypes.ExecutorName) ethMapFromExecutor := conf.GStr("ethMapFromExecutor") @@ -488,6 +524,13 @@ const ( // Transfer 借助coins执行器进行转账相关操作 func (mdb *MemoryStateDB) Transfer(sender, recipient string, amount uint64) bool { log15.Debug("transfer from contract to external(contract)", "sender", sender, "recipient", recipient, "amount", amount) + // 账户黑名单兜底:收发任一方命中名单即拒绝转账(返回 false,由上层触发 revert)。 + // sender 维度覆盖 SELFDESTRUCT / DELEGATECALL+SELFDESTRUCT 打出路径,见 isBlockedAccount 注释。 + if mdb.isBlockedAccount(sender, recipient) { + log15.Error("Transfer blocked account", "sender", sender, "recipient", recipient, + "amount", amount, "height", mdb.blockHeight) + return false + } var ( ret *types.Receipt err error @@ -533,6 +576,13 @@ func (mdb *MemoryStateDB) Transfer(sender, recipient string, amount uint64) bool // TransferToToken evm call token func (mdb *MemoryStateDB) TransferToToken(from, recipient, symbol string, amount int64) (bool, error) { + // 账户黑名单兜底:收发任一方命中名单即拒绝 token 转账。 + // from 维度是 token 预编译第三方代打路径的唯一防线,见 isBlockedAccount 注释。 + if mdb.isBlockedAccount(from, recipient) { + log15.Error("TransferToToken blocked account", "from", from, "recipient", recipient, + "symbol", symbol, "amount", amount, "height", mdb.blockHeight) + return false, fmt.Errorf("%w: token transfer %s -> %s", types.ErrBlockedAccount, from, recipient) + } tokenInfo, err := mdb.tokenStatus(symbol) if err != nil { return false, err diff --git a/plugin/dapp/evm/executor/vm/state/statedb_test.go b/plugin/dapp/evm/executor/vm/state/statedb_test.go index fc53748841..46cb66bbde 100644 --- a/plugin/dapp/evm/executor/vm/state/statedb_test.go +++ b/plugin/dapp/evm/executor/vm/state/statedb_test.go @@ -1,6 +1,7 @@ package state import ( + "errors" "math" "testing" @@ -178,3 +179,54 @@ func TestMemoryStateDBAddLogWithNoTopics(t *testing.T) { t.Fatalf("expected address %s, got %s", contractAddr.String(), evmLog.GetAddress()) } } + +// TestBlacklistBlocksFundOps 验证 ForkAccountBlacklist 激活后,黑名单地址的 +// CanTransfer / Transfer / TransferToToken 三个兜底路径均被拦截。 +// 覆盖 statedb.go 的黑名单分支(isBlockedAccount 命中 → 返回失败)。 +func TestBlacklistBlocksFundOps(t *testing.T) { + cfg := ctypes.NewChain33Config(ctypes.GetDefaultCfgstring()) + api := new(apimock.QueueProtocolAPI) + api.On("GetConfig").Return(cfg) + + dbDir, stateDB, localDB := util.CreateTestDB() + defer util.CloseTestDB(dbDir, stateDB) + + coinsAccount, err := account.NewAccountDB(cfg, "coins", cfg.GetCoinSymbol(), stateDB) + if err != nil { + t.Fatalf("failed to create coins account: %v", err) + } + + execAddr := address.ExecAddress(cfg.ExecName("evm")) + mdb := NewMemoryStateDB(stateDB, localDB, coinsAccount, 1, api) + mdb.evmPlatformAddr = execAddr + + // 启用黑名单 fork:local 标题下 SetAllFork(0),ForkAccountBlacklist 从高度 0 生效 + cfg.SetFork(ctypes.ForkAccountBlacklist, 0) + + blockedAddr := "14KEKbYtKKQm4wMthSK9J4La4nAiidGozt" + restore := ctypes.SetBlockedAccountsForTest([]string{blockedAddr}) + t.Cleanup(restore) + + // CanTransfer:黑名单发送方拒绝 + if mdb.CanTransfer(blockedAddr, 100) { + t.Fatal("CanTransfer accepted blacklisted sender") + } + + // Transfer:黑名单发送方或接收方拒绝 + if mdb.Transfer(blockedAddr, execAddr, 100) { + t.Fatal("Transfer accepted blacklisted sender") + } + if mdb.Transfer(execAddr, blockedAddr, 100) { + t.Fatal("Transfer accepted blacklisted recipient") + } + + // TransferToToken:黑名单发送方拒绝并返回 ErrBlockedAccount + ok, err := mdb.TransferToToken(blockedAddr, execAddr, "BTY", 100) + if ok { + t.Fatal("TransferToToken accepted blacklisted sender") + } + if !errors.Is(err, ctypes.ErrBlockedAccount) { + t.Fatalf("expected ErrBlockedAccount, got %v", err) + } + t.Log("✓ blacklist blocks CanTransfer/Transfer/TransferToToken") +} diff --git a/plugin/dapp/ticket/executor/testdata/chain33.cfg.toml b/plugin/dapp/ticket/executor/testdata/chain33.cfg.toml index 3ea1726f6d..5be99391b9 100644 --- a/plugin/dapp/ticket/executor/testdata/chain33.cfg.toml +++ b/plugin/dapp/ticket/executor/testdata/chain33.cfg.toml @@ -220,6 +220,9 @@ ForkTicketFundAddrV1=-1 #fork6.3 ForkRootHash =1 #地址key格式化, 主要针对eth地址 ForkFormatAddressKey=0 +ForkParaFee=-1 +# 账户黑名单启用高度;-1 表示关闭 +ForkAccountBlacklist=-1 [fork.sub.coins] Enable=0