Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions docs/security/evm-account-blacklist.md
Original file line number Diff line number Diff line change
@@ -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 入口无门控是故意的:它是节点本地行为、不进状态计算,随二进制升级立即止血。
49 changes: 45 additions & 4 deletions plugin/dapp/evm/executor/attack_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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")
})
})
}
9 changes: 9 additions & 0 deletions plugin/dapp/evm/executor/evm.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package executor

import (
"bytes"
"encoding/hex"
"fmt"
"math/big"
"os"
Expand Down Expand Up @@ -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())
}

Expand Down
42 changes: 42 additions & 0 deletions plugin/dapp/evm/executor/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 == "" {
Expand Down
84 changes: 84 additions & 0 deletions plugin/dapp/evm/executor/vm/runtime/account_blacklist_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading
Loading