diff --git a/chain33.fork.toml b/chain33.fork.toml index 79c79eece..c13cf636f 100644 --- a/chain33.fork.toml +++ b/chain33.fork.toml @@ -220,6 +220,8 @@ ForkStorageLocalDB=0 Enable=0 ForkTicketId=1062000 ForkTicketVrf=1770000 +# TicketOpen 的 MinerAddress 归属校验(H-2):MinerAddress 只能是签名者自己,或 ReturnAddress 通过 Tbind 授权的矿工 +ForkTicketMinerAuth=48100000 # 拒绝同一笔 tclose 交易内重复的 ticketId(重复会等价于同一张票被多次付款) ForkTicketCloseDup=0 ForkMinerExecerCheck=0 diff --git a/chain33.para.toml b/chain33.para.toml index 00afc4248..2e417f143 100644 --- a/chain33.para.toml +++ b/chain33.para.toml @@ -359,6 +359,8 @@ Enable=0 Enable=0 ForkTicketId =0 ForkTicketVrf =0 +# TicketOpen 的 MinerAddress 归属校验(H-2):MinerAddress 只能是签名者自己,或 ReturnAddress 通过 Tbind 授权的矿工 +ForkTicketMinerAuth =0 # 拒绝同一笔 tclose 交易内重复的 ticketId(重复会等价于同一张票被多次付款) ForkTicketCloseDup=0 ForkMinerExecerCheck=0 diff --git a/chain33.para.toml.readme b/chain33.para.toml.readme index 016c0b3c4..df55a4fa0 100644 --- a/chain33.para.toml.readme +++ b/chain33.para.toml.readme @@ -275,6 +275,8 @@ Enable=0 Enable=0 ForkTicketId =0 ForkTicketVrf =0 +# TicketOpen 的 MinerAddress 归属校验(H-2):MinerAddress 只能是签名者自己,或 ReturnAddress 通过 Tbind 授权的矿工 +ForkTicketMinerAuth =0 # 拒绝同一笔 tclose 交易内重复的 ticketId(重复会等价于同一张票被多次付款) ForkTicketCloseDup=0 ForkMinerExecerCheck=0 diff --git a/chain33.toml b/chain33.toml index 6585bfc56..61fa13f7d 100644 --- a/chain33.toml +++ b/chain33.toml @@ -273,6 +273,10 @@ signType="secp256k1" [wallet.sub.ticket] minerdisable=false +# 矿工本地批准的 ReturnAddress 白名单: +# - 买票时只动用名单内冷地址的票面资金; +# - 选票(挖矿)时只挖自挖票和名单内 ReturnAddress 的票,避免第三方把票挂到本矿工名下占用算力; +# ["*"] 表示不限制(保持原有行为)。 minerwhitelist=["*"] [wallet.sub.multisig] diff --git a/docs/security/ticket-topen-mineraddress-injection-analysis.md b/docs/security/ticket-topen-mineraddress-injection-analysis.md new file mode 100644 index 000000000..f6ff4ed09 --- /dev/null +++ b/docs/security/ticket-topen-mineraddress-injection-analysis.md @@ -0,0 +1,320 @@ +# Ticket 票据归属缺陷(审计项 H-2)与 Genesis 重入分析 + +> 代码版本:`plugin` HEAD `92b68766e`,依赖 `chain33 v1.72.0` +> 分析时间:2026-10-15(含独立复核代理的交叉验证与修正) +> 可执行证据: +> ① 执行器级 PoC:`plugin/dapp/ticket/executor/ticket_miner_inject_poc_test.go`(6 个用例,全部通过;其中演示“注入”的用例显式把 `ForkTicketMinerAuth` 配到未来高度,固定修复前的旧行为) +> ② 真实测试节点端到端 PoC:`plugin/dapp/ticket/testnode/genesis_reentry_attack_test.go`(mempool → 出块 → 执行 → 票共识挖票 → 奖励结算,通过) +> 复现:`go test -run 'Test_TicketOpen_Injects|Test_InjectedTicket|Test_TicketGenesisReRun|Test_TicketOpen_CountExceeds' -v ./plugin/dapp/ticket/executor/` +>    `go test -run 'TestGenesisReentryAttackOnTicketNode' -v ./plugin/dapp/ticket/testnode/` +> +> 修复状态(本 PR,见 §8): +> 1. **执行器层**:新增 dapp 分叉 `ticket.ForkTicketMinerAuth`,**默认关闭**(`InitFork` +> 注册为 `types.MaxHeight`,必须由链配置显式指定激活高度),拒绝 `MinerAddress` 既非 +> 签名者、也非其绑定矿工的开票;主链配置为 `48100000`。 +> 2. **矿工侧(无需分叉)**:选票入口按 `wallet.sub.ticket.minerwhitelist`(ReturnAddress +> 白名单)过滤,只挖自挖票与名单内的票。这是矿工本地的“批准名单”:执行器校验挡不住 +> 自签 `Tbind` 的挂票(`Tbind` 只由 ReturnAddress 签名,见 §8.3),最终是否消耗算力 +> 由矿工节点自己决定。 +> 3. **§3 的 `Exec_Genesis` 重入由独立提交修复**(`Exec_Genesis` 增加 `height != 0` 校验), +> 与本 PR 互不依赖。 + +## 1. 结论 + +| 问题 | 结论 | +| --- | --- | +| “向任意矿工注入票据”能否实现 | **可以**。攻击者无需受害矿工私钥或任何授权,只需以自己地址作 `ReturnAddress` 签一笔 `Topen`(或 `Genesis`) | +| 报告“盗取奖励”的危害 | **成立,且分叉生效后同样成立**——但分叉后的路径不是 `Topen`(会被 `pubHash` 挡住),而是 **`Exec_Genesis` 缺少 `height != 0` 保护导致的重入**(§3,严重/高危) | +| `Topen` 注入在 `ForkTicketId` 生效后的单独危害 | **中危**:票据集合/本地索引污染、替受害矿工出废块、链上归因伪造、清理受限;不盗币、不实质掉速 | +| `Count > len(PubHashes)` panic | **低–中危**(修正):panic 被 `DriverBase.Exec` 的 recover 兜住 → `ErrActionNotSupport`,自挖块会丢弃该笔交易,节点继续出块,**不会停摆** | +| 修复 | **`MinerAddress` 归属校验与 PubHashes 越界见 §8(本 PR)**;`Exec_Genesis` 重入见独立提交 | + +一句话:**H-2 的根因(不校验 `MinerAddress` 归属)真实存在;它所声称的盗币危害在现网(`ForkTicketId` 已生效)依然成立,但要借道另一个缺陷——`Ticket.Exec_Genesis` 没有像 coins/coinsx/token 那样拒绝高度非 0 的重入。** + +## 2. 根因 A:`TicketOpen` 不校验 `MinerAddress` 归属 + +`plugin/dapp/ticket/executor/ticketdb.go:243-251`: + +```go +//addr from +if action.fromaddr != topen.ReturnAddress { + mineraddr := action.getBind(topen.ReturnAddress) + if mineraddr != action.fromaddr { + return nil, ty.ErrMinerNotPermit + } + if topen.MinerAddress != mineraddr { + return nil, ty.ErrMinerAddr + } +} +``` + +归属校验整体被放进 `fromaddr != ReturnAddress` 的分支。调用方只要把自己的地址填进 `ReturnAddress`(最常见的自挖写法),`MinerAddress` 就完全不受约束——不必等于 `fromaddr`,也不必等于 `ReturnAddress` 绑定的矿工。`Exec_Topen`(`exec.go:26-35`)也没有 `address.CheckAddress`(对比 `TicketBind` 有:`ticketdb.go:218-222`)。冻结仍发生在调用方自己的 `ReturnAddress` 上(`ticketdb.go:266`),链上没有任何威慑。 + +放大危害的是索引与授权都绑定 `MinerAddress`: + +- 回执地址标签 `ReceiptTicket.Addr = t.MinerAddress`(`ticketdb.go:92`)→ 本地索引 `LODB-ticket-tl:::`(`exec_local.go:16-23`、`ticket.go:116-137`); +- 钱包按账户地址(= `MinerAddress`)取票(`wallet/exec.go:30-34`、`wallet/ticket.go:475-510`); +- 共识 `searchTargetTicket` 要求 `privmap[ticket.MinerAddress]` 存在(`consensus/ticket/ticket.go:582`,`getPrivMap` 见 `:250-274`)。 + +因此“把 `MinerAddress` 写成受害矿工”= 让受害节点的钱包与共识把票当成自己的票。 + +## 3. 根因 B(关键):`Exec_Genesis` 没有高度保护,使分叉后仍可造出“免检”票据 + +`plugin/dapp/ticket/executor/exec.go:14-23`: + +```go +func (t *Ticket) Exec_Genesis(payload *ty.TicketGenesis, tx *types.Transaction, index int) (*types.Receipt, error) { + if payload.Count <= 0 { + return nil, ty.ErrTicketCount + } + payload.MinerAddress = common.FmtEthAddressWithFork(...) + payload.ReturnAddress = common.FmtEthAddressWithFork(...) + actiondb := NewAction(t, tx) + return actiondb.GenesisInit(payload) // 没有任何 height/fork 检查 +} +``` + +对照同类执行器,都有重入保护并返回 `types.ErrReRunGenesis`: + +- chain33 `system/dapp/coins/executor/exec.go:58-71`(`if c.GetHeight() == 0 { ... } return nil, types.ErrReRunGenesis`) +- `plugin/dapp/coinsx/executor/exec.go:71-79` +- `plugin/dapp/token/executor/transwithdraw.go:39-46` + +chain33 执行器/驱动层也没有针对 Genesis action 的全局防护(`types`/`executor`/`system/dapp` 中不存在 `GenesisAction`/`IsGenesisTx` 之类的校验)。票据 `CheckTx` 在 mempool 只拒绝 `TicketActionMiner`(`executor/ticket.go:182-195`),所以一笔 `TicketAction{Ty=11(Genesis)}` 的普通用户交易可以正常进块并执行。 + +`GenesisInit`(`ticketdb.go:147-171`)生成的是**创世格式票据**: + +```go +id := prefix + fmt.Sprintf("%010d", i) // 3 段 miner:txhash:index,无 pubHash +t := NewDB(..., genesis.MinerAddress, genesis.ReturnAddress, ..., /*isGenesis*/ true) +``` + +这带来三重“免检”: + +1. **免 `pubHash` 校验**:3 段 `ticketId` 下 `genPrivHash` 返回空(`consensus/ticket/ticket.go:654-670`),`TicketMiner` 的校验条件 `len(miner.PrivHash) != 0` 不成立而被整段跳过(`ticketdb.go:328-337`); +2. **免成熟期**:`TicketMiner` 只对 `!ticket.IsGenesis` 校验 `TicketFrozenTime`(`ticketdb.go:318-322`),创世票立刻可挖; +3. **免提现等待**:`TicketClose` 的时间校验同样只对 `!ticket.IsGenesis` 生效(`ticketdb.go:396-408`),攻击者可当场 `Tclose` 收回票面价 + 奖励(`ticketdb.go:424-425`)。 + +于是分叉生效后仍存在完整的盗币链: + +``` +攻击者签 Topen/Genesis 交易(MinerAddress=受害矿工, ReturnAddress=攻击者, Count=N) + → 受害节点按 MinerAddress 把 N 张票纳入 ticketsMap(§2) + → 受害节点正常出块,抽中这些票时 Exec_Miner 直接通过(免检 1、2) + → ExecDepositFrozen(ReturnAddress, MinerValue) 增发奖励到攻击者冻结余额(ticketdb.go:340,348) + → 攻击者立刻 Tclose(免检 3)拿走票面价 + 奖励,本金零锁仓、逐块滚动复用 +``` + +攻击者成本仅手续费;受害矿工损失的是自己算力本应获得的出块奖励。奖励流向说明:`ExecDepositFrozen` 的语义是**增发新币并冻结给 `addr`**(chain33 `account/execaccount.go:232-268`,增发见 `account/account.go:160-175`),`addr` 是票据 `ReturnAddress`——**奖励从来不给 `MinerAddress`**。 + +## 4. 危害分析 + +### 4.1 `ForkTicketId` 已生效:借道 Genesis 重入盗取奖励(严重) + +**执行器级 PoC**(修复前的 `Test_TicketGenesisReRun_PostFork_RewardGoesToAttacker`,随 `Exec_Genesis` 修复已改为回归断言 `Test_TicketGenesisReRunRejected_PostFork`)在 `height=1,700,000`(> `ForkTicketId=1,600,000`,断言分叉已生效)当时实测: + +``` +post-fork(height=1700000) genesis re-run: victim 1JRNjdEqp4LJ5fqycUBm9ayCKSeeskgMKR + mined, reward 500000000 credited to attacker 1KSBd17H7ZK8iT37aJztFB22XGwsPTdwE4, closed immediately +``` + +**真实测试节点端到端 PoC**(`plugin/dapp/ticket/testnode/genesis_reentry_attack_test.go`,默认配置下 `ForkTicketId=0` 创世即生效,全程走钱包/mempool → 出块 → 执行器 → 票共识选票挖票 → 奖励结算): + +``` +node height=3 ForkTicketId=true ticketPrice=1000000000000 coinReward=1800000000 +attacker=1EbDHAXpoiewjPLX9uqoz38HsKqMXayZrF victim=12qyocayNF7Lv6C9qW4avxs2E7U41fKSfv +harvested: attacker ticket balance=1000003600000000 frozen=0 -> forged count=1000 +victim node height=77 minedForged=1 attacker delta=1800000000 (coinReward=1800000000) minerValue=[1800000000] +attacker closed a mined forged genesis ticket immediately at height=81 +--- PASS: TestGenesisReentryAttackOnTicketNode (9.02s) +``` + +要点:一笔 `Genesis` 交易在高度 >0 被执行(`ExecOk`),造出 1000 张 `IsGenesis=true`、3 段 `ticketId` 的票据挂在受害矿工 `12qyocay` 名下;受害节点在高度 77 把它挖出(`minerValue=1800000000`);攻击者地址的余额增量恰好等于 1 × `coinReward`(该地址已无其它收入来源,故可精确归因);随后攻击者在高度 81 立即 `Tclose` 成功(创世票跳过 `TicketWithdrawTime`)。 + +### 4.2 `ForkTicketId` 未生效(或分叉前预埋 3 段票):`Topen` 注入直接盗取奖励(高) + +分叉未生效时 `ticketId` 只有 3 段(`ticketdb.go:254-262`),`pubHash` 校验整段跳过(`ticketdb.go:328-330`;`genPrivHash` 对 3 段 id 返回空)。攻击者签 `MinerAddress=受害矿工` 的 `Topen` 即可;票据无过期机制(`searchTargetTicket` 只校验成熟时间,`consensus/ticket/ticket.go:577-580`),**分叉前预埋的票在分叉后依然被放行**(这就是 §3 的第 1 条“免检”在时间维度上的表现)。PoC 4 实测奖励 5 币记到攻击者地址。 + +现网配置:`chain33.para.toml:358`、chain33 默认注册(`plugin/dapp/ticket/types/ticket.go:81`)均为 `ForkTicketId=0`(创世即生效),主链配置无该项亦为 0;`chain33.fork.toml:212` 为 `1062000`、执行器测试配置为 `1600000`——**凡尚未到达该高度的链都处于 4.1/4.2 的直接暴露状态**。 + +### 4.3 `ForkTicketId` 已生效且不使用 Genesis:污染 / 废块 / 归因(中) + +只看 `Topen` 注入时,`pubHash` 由开票人私钥派生(`wallet/ticket.go:662-669`),受害矿工用自己的私钥算出的 `privHash` 必然对不上 → `ErrCheckPubHash`(`ticketdb.go:331-337`),PoC 2 已验证。剩余危害: + +1. **票据集合/本地索引污染**:注入票进入 `ticketsMap` 与 `LODB-ticket-tl:<矿工>:1`,每次挖矿尝试遍历并哈希全部票(O(N) 次 sha256);共识侧 `flushTicket`(默认 3600s,`consensus/ticket/ticket.go:42,99-111,234-248`)与钱包事件会整表拉取重建; +2. **替受害矿工出废块**:注入票先满足难度时,节点组装并提交区块,coinbase 执行失败 → 自挖路径 `PreExecBlock` 丢弃失败的 coinbase(chain33 `util/util.go:381-396`,`blockchain/process.go:296` 中 `errReturn=false`),随后 `CheckBlock` 因 `Txs[0]` 不再是矿工交易而报 `ErrCoinBaseTxType`(`consensus/ticket/ticket.go:288-290,343`)→ 区块被拒 → `WriteBlock` 失败 → `CreateBlock` 用同一个 `block` 无限重试(`consensus/ticket/ticket.go:614-647,808-819`)。多矿工链会被别人出的块打断恢复;单矿工链若注入票长期占据首个合格位则可能停滞; +3. **链上归因伪造**:回执与 `TicketList(受害矿工)` 显示受害矿工名下有自己没开过的票,可用于甩锅/合规误判/监控误报; +4. **清理受限**:自动 close 只处理 `status=2`(`wallet/ticket.go:520-549`),`status=1` 的注入票不被清理;只能等 `TicketWithdrawTime`(主网 172800s ≈ 2 天,`chain33.toml:196`)后由运营方手动 force-close(`wallet/exec.go:13-27`、`wallet/ticket.go:359-397,436-450`),期间攻击者可滚动续投。 + +**定量说明(为什么不按“停摆”定级)**:设单票单次尝试满足难度的概率为 `q`、受害自有票数 `M`、注入票数 `N`,则 `P(单次尝试成功) ≈ M(1-e^{-Nq})/N`;`Nq ≪ 1` 时与未注入相同(注入票只是“只输不赢”的抽签券,且攻击者无法定向提高难度——难度哈希含受害矿工的 `privHash`)。显著掉速需要 `N ≳ 1/q`(量级≈全网票池),资本门槛极高。注意这与 §4.1 不同:Genesis 票一旦被抽中是**成功出块并付钱给攻击者**,所以是小额即可持续收割,而非掉速。 + +### 4.4 与报告定级的对照 + +报告的“高”应当保留,但需要把攻击链写清楚:**根因是 H-2(`MinerAddress` 无归属校验),在 `ForkTicketId` 未生效时用 `Topen` 直接利用;在 `ForkTicketId` 已生效时用 `Exec_Genesis` 重入(缺 height 保护)+ 同一个 `MinerAddress` 无校验来利用。** 若只按“分叉后 `Topen` 注入能否偷钱”评估,会得出“不能”的错误结论。 + +## 5. 相邻缺陷:`Count > len(PubHashes)` panic(修正为低–中危) + +`ticketdb.go:257-262` 只拒绝 `len(topen.PubHashes) == 0`,随后按 `Count` 下标访问 `topen.PubHashes[i]`;`Count=1000, PubHashes=[1 项]` → `runtime error: index out of range [1] with length 1`(修复前由 PoC 5 验证,现已改为回归断言)。 + +链上后果(PoC 5 + 代码 + 独立复核实测): + +- mempool 不拦:票据 `CheckTx` 只拒绝 `TicketActionMiner`(`executor/ticket.go:182-195`),chain33 mempool 检查只调用 `CheckTx`(`system/mempool/check.go:189-209`),手续费/基本检查通过; +- panic 被 **`DriverBase.Exec` 的 recover** 兜住并返回 `types.ErrActionNotSupport`(chain33 `system/dapp/driver.go:296-307`)——不是 `ErrExecPanic`(`executor.Exec` 的 recover 在 `execenv.go:251-261`,层级更高,看不到这个 panic)。修复前的 PoC 5 断言的正是 `ErrActionNotSupport`;该修复不设分叉保护,修复后 `Test_TicketOpen_CountExceedsPubHashesRejected` 断言返回 `ErrOpenTicketPubHash` 且不再 panic; +- 自挖路径 `PreExecBlock` 会把执行失败(`ExecErr`)的交易从区块中剔除并重算状态根(chain33 `util/util.go:381-396`),`WriteBlock` 的 diff 逻辑再把它从 mempool 清除(`system/consensus/base.go:479-490`);因此节点会继续正常出块(复核实测高度 435→600 约 20s),**不会停摆**;非法区块也不会被广播(广播发生在 `connectBlock` 成功之后); +- 残余风险:这属于健壮性缺陷——目前唯一阻止节点 panic 的就是驱动层的 recover,一旦该 recover 被绕过或调整(例如执行器直接调用 action),就会变成进程级崩溃。 + +修复:`if len(topen.PubHashes) < int(topen.Count) { return nil, ty.ErrOpenTicketPubHash }`。 + +## 6. 修复建议(按优先级) + +1. **Critical — `Exec_Genesis` 补高度保护**(`plugin/dapp/ticket/executor/exec.go:14`),与 coins/coinsx/token 对齐: + +```go +func (t *Ticket) Exec_Genesis(payload *ty.TicketGenesis, tx *types.Transaction, index int) (*types.Receipt, error) { + if t.GetHeight() != 0 { + return nil, types.ErrReRunGenesis + } + ... +} +``` + +2. **High — `Exec_Topen` 补归属与地址校验**(保留自挖与冷地址绑定两条合法路径): + +```go +if err := address.CheckAddress(topen.MinerAddress, action.height); err != nil { + return nil, err +} +bindMiner := action.getBind(topen.ReturnAddress) // 可能为空 +if topen.MinerAddress != action.fromaddr && topen.MinerAddress != bindMiner { + return nil, ty.ErrMinerAddr +} +if action.fromaddr != topen.ReturnAddress && bindMiner != action.fromaddr { + return nil, ty.ErrMinerNotPermit +} +``` + +兼容性:`buyTicketOne`(`wallet/ticket.go:717`,miner == return == 自己)与 `buyMinerAddrTicketOne`(`:819`,miner = 自己且已与冷地址绑定)都满足新条件;被挡掉的只有“用任意 `MinerAddress` 开票”。 + +3. **Medium — 消除 panic**:`len(PubHashes) < Count` 直接返回 `ErrOpenTicketPubHash`。 + +4. **纵深防御**: + - 分叉生效后,`TicketMiner` 对 3 段(旧格式/创世格式)`ticketId` 应直接拒绝,而不是跳过 `pubHash` 校验——这能同时封堵“分叉前预埋票”和“Genesis 重入”两类绕过; + - `searchTargetTicket` 跳过 `ReturnAddress` 未与本节点绑定的票据,或至少告警; + - 票据增加最大未挖高度/过期机制,避免陈票长期占用票池与索引; + - `Topen` 的 `Count` 加合理上限(当前仅 `> 0`)。 + +## 7. 验证范围与残余不确定性 + +**已由可执行 PoC 验证**: + +执行器级(`plugin/dapp/ticket/executor/ticket_miner_inject_poc_test.go`,6/6 通过;注入类用例显式固定 `ForkTicketMinerAuth` 未生效的配置): + +| 用例 | 结论 | +| --- | --- | +| `Test_TicketOpen_InjectsTicketsToArbitraryMiner` | 第三方可把票据挂到任意矿工名下;`LODB-ticket-tl:<受害矿工>:1` 生成;官方 `TicketList(受害矿工)` 返回这些票 | +| `Test_InjectedTicket_NotMinableByVictim_PostFork` | 分叉后受害矿工挖 `Topen` 注入票 → `ErrCheckPubHash`(不盗币) | +| `Test_TicketGenesisReRunRejected_PostFork` / `Test_TicketGenesisAllowedAtHeightZero` | 修复后回归:高度 1.7M 的 `Exec_Genesis` 被框架层以 `ErrReRunGenesis` 拒绝;高度 0 的正常创世路径不受影响 | +| `Test_InjectedTicket_MinedByVictim_PreFork_RewardGoesToAttacker` | 分叉前 `Topen` 注入可被受害矿工挖出,奖励记到攻击者地址 | +| `Test_TicketOpen_CountExceedsPubHashesRejected` | 修复后回归:`Count > len(PubHashes)` 返回 `ErrOpenTicketPubHash`,不再 panic(该修复不设分叉保护) | + +真实测试节点端到端(`plugin/dapp/ticket/testnode/genesis_reentry_attack_test.go`,通过):默认配置(`ForkTicketId=0`)下发送一笔 `Genesis` 交易 → 上链执行成功 → 1000 张 `IsGenesis` 3 段票据挂在受害矿工名下 → 受害节点在高度 77 挖出 → 攻击者地址余额增量 = 1 × `coinReward` → 攻击者在高度 81 立即关闭提款。 + +**H-2 修复回归**(`plugin/dapp/ticket/executor/ticket_miner_auth_test.go`,本 PR,8/8 通过):第三方开票被 `ErrMinerAddr` 拒绝且不动用票面资金;自挖与 `Tbind` 绑定代挖路径不受影响;非法 `MinerAddress` 被 `CheckAddress` 拒绝;`Count > len(PubHashes)` 返回错误而非 panic;分叉高度之前保持旧行为;分叉默认关闭;并固定已知限制“自签 Tbind 仍可挂票”(由矿工侧白名单兜底)。 + +**矿工侧白名单回归**(`plugin/dapp/ticket/wallet/ticket_test.go`,`TestFilterTrustedTickets`):默认 `minerwhitelist=["*"]` 时不过滤(保持原有挖矿行为);显式白名单时只保留自挖票与白名单 ReturnAddress 的票,注入票被过滤并打 `skipUntrustedTicket` 告警。详见 §8.2。 + +**由代码推导 + 独立复核实测、但未纳入本仓库测试**:灌入票据后节点重试/停滞的时序(多矿工 vs 单矿工);panic 交易被 `PreExecBlock` 丢弃后节点继续出块(复核在临时节点上实测 435→600)。**未做大规模仿真**:§4.3 的抽签模型为解析推导。 + +本次分析未改动业务代码;修复代码与回归用例见 §8,另附 PoC 测试文件与本文档。 + +## 8. 修复实现(H-2,本 PR) + +修复分两层:执行器层用**默认关闭**的分叉收紧开票校验;矿工侧用**无需分叉**的 ReturnAddress +白名单过滤选票。前者阻止“裸挂”上链,后者让矿工不再替别人出块;两层互相补充。 + +### 8.1 执行器层:新增 dapp 分叉 `ticket.ForkTicketMinerAuth`(默认关闭) + +`InitFork` 把该分叉注册为 **`types.MaxHeight`(关闭)**,只有链配置 +`[fork.sub.ticket] ForkTicketMinerAuth` 显式指定激活高度后才启用。这一点很关键:链配置里 +没有 `[fork]` 段时(例如部署仓的 `bityuan.toml`)`initForkConfig` 会被跳过,分叉静默按注册 +默认值生效;若默认注册为 0,升级二进制就会立刻在所有这类链上启用本规则,既可能让历史区块 +重放失败,也会改变当下的开票/挖矿行为。回归:`TestForkTicketMinerAuthDefaultDisabled`。 + +1. `Action.TicketOpen`(`plugin/dapp/ticket/executor/ticketdb.go`)新增强制校验: + - `address.CheckAddress(MinerAddress, height)`:非法地址直接拒绝; + - `MinerAddress ∈ {fromaddr, getBind(ReturnAddress)}`:只能挂在自己名下,或挂在 + `ReturnAddress` 已通过 `Tbind` 明确授权的矿工名下,否则返回 `ty.ErrMinerAddr`。 + 原有“非 `ReturnAddress` 签名者必须是该 `ReturnAddress` 绑定矿工”的逻辑保持不变, + 因此 `buyTicketOne`(自挖)与 `buyMinerAddrTicketOne`(冷地址绑定矿工)两条合法路径不受影响。 + 注意:这条规则只能挡住“未绑定”的裸挂,挡不住自签 `Tbind`,见 §8.3。 +2. 同一函数修掉 `Count > len(PubHashes)` 的越界 panic:`len(topen.PubHashes) == 0` + 改为 `len(topen.PubHashes) < int(topen.Count)`,返回 `ErrOpenTicketPubHash`。 + 该修复无需分叉保护:panic 交易会被 `PreExecBlock` 从区块中丢弃,从未写入任何区块, + 因此不影响历史区块重放。 +3. 分叉高度配置: + - 主链 `chain33.fork.toml` = `48100000`、镜像主链的 `plugin/dapp/evm/cmd/ci2/chain33.proxyminer.toml` + 同为 `48100000`(按仓库既有做法取 `tip + 241920` ≈ 两周升级窗口,主链 2026-10-15 高度约 47.85M); + - `chain33.para.toml`、`chain33.para.toml.readme`、`plugin/dapp/ticket/rpc/rpc_test.go` 内嵌配置、 + `plugin/dapp/ticket/executor/testdata/chain33.cfg.toml` = `0`(平行链/测试配置直接生效); + - **未显式配置该键的链保持关闭**(注册默认值 `types.MaxHeight`),升级二进制本身不改变行为; + - 分叉高度必须留在升级窗口之后:`Test_TicketOpen_LegacyPathBeforeFork` 断言分叉高度之前仍按旧逻辑放行。 + +### 8.2 矿工侧(无需分叉):ReturnAddress 白名单过滤选票 + +执行器规则无法挡住“自签 `Tbind`”的挂票(§8.3),而这类票的危害发生在**选票/出块**环节: +本节点挖出票后,`ExecDepositFrozen` 会把出块奖励增发给票的 `ReturnAddress`。因此本 PR 在 +选票入口增加本地过滤,不改变任何链上状态,**不需要分叉**: + +- 位置:`plugin/dapp/ticket/wallet/exec.go` 的 `On_WalletGetTickets`(共识 `flushTicket` + 通过 `WalletGetTickets` 取票的唯一入口)→ `filterTrustedTickets`; +- 规则:只保留 `MinerAddress == ReturnAddress`(自挖)或 `ReturnAddress` 命中 + `wallet.sub.ticket.minerwhitelist` 的票;被过滤的票打 `skipUntrustedTicket` 告警; +- `minerwhitelist` 就是矿工本地的**批准名单**:只有名单内的 ReturnAddress 才能使用本矿工的 + 挖矿身份。默认 `["*"]` 表示不过滤,保持原有挖矿行为;链上不新增任何批准动作; +- 局限:只保护升级并配置了白名单的节点;不阻止攻击者把票发上链,也不清除链上票据与本地 + 索引(`TicketList` 仍能查到),即“链上归因伪造”仍在。它解决的是算力被占用、奖励被 + 拿走这一核心危害。 + +回归:`TestFilterTrustedTickets`(默认 `*` 不过滤;显式白名单时注入票被过滤)。 + +### 8.3 为什么 `Tbind` 不能当作“矿工侧批准” + +`Tbind`(`Action.TicketBind`)要求 `fromaddr == ReturnAddress`,即**票主单方签名**,矿工本人 +不签名;它表达的是“票主授权某矿工动用我的票面资金”,不是“矿工同意别人把票挂到我名下”。 +因此攻击者可以自签 `Tbind{ReturnAddress=自己, MinerAddress=受害矿工}`,随后 +`Topen{MinerAddress=受害矿工, ReturnAddress=自己}` 就能通过 8.1 的绑定分支(回归用例 +`Test_TicketOpen_SelfTbindStillInjects` 固定这一已知限制,票据仍会以受害矿工为 `MinerAddress`)。 +要链上强制“矿工批准”,需要新增矿工签名的批准动作(新 action + 分叉 + 钱包支持,历史绑定还需 +迁移),不在本 PR 范围;本 PR 用 §8.2 的本地白名单承担矿工侧批准。 + +### 8.4 合并上游的适配 + +已入库的 H-2 PoC 文件(`plugin/dapp/ticket/executor/ticket_miner_inject_poc_test.go`)中演示 +“注入成功”的用例显式改用 `ForkTicketMinerAuth` 未生效的配置(`pocCfgBeforeMinerAuth`),并断言 +该前提,从而继续固定修复前行为;`Count > len(PubHashes)` 用例改为断言 `ErrOpenTicketPubHash` +且不再 panic。 + +### 8.5 回归用例 + +执行器层(`plugin/dapp/ticket/executor/ticket_miner_auth_test.go`): + +| 用例 | 断言 | +| --- | --- | +| `TestForkTicketMinerAuthDefaultDisabled` | 没有 `[fork]` 段的链配置下分叉默认关闭(`types.MaxHeight`),升级不改变旧行为 | +| `Test_TicketOpen_RejectsForeignMinerAddress` | 第三方把票挂到别的矿工 → `ErrMinerAddr`,且校验发生在冻结之前(票面资金未被动用) | +| `Test_TicketOpen_AllowsOwnMinerAddress` | 自挖路径照常 `ExecOk`,票据以自己为 `MinerAddress` 落地 | +| `Test_TicketOpen_AllowsBoundMinerAddress` | 票主开票给已绑定矿工、绑定矿工代开票,两条路径都放行 | +| `Test_TicketOpen_RejectsInvalidMinerAddress` | 非法 `MinerAddress` 被地址校验拒绝 | +| `Test_TicketOpen_RejectsPubHashesCountMismatch` | `Count > len(PubHashes)` 返回 `ErrOpenTicketPubHash`,不再 panic | +| `Test_TicketOpen_LegacyPathBeforeFork` | 分叉高度之前保持旧行为,说明必须留足升级窗口 | +| `Test_TicketOpen_SelfTbindStillInjects` | 已知限制:自签 `Tbind` 仍可把票挂到任意矿工(由 §8.2 兜底) | + +矿工侧(`plugin/dapp/ticket/wallet/ticket_test.go`): + +| 用例 | 断言 | +| --- | --- | +| `TestFilterTrustedTickets` | 默认 `["*"]` 不过滤;显式白名单时只保留自挖票与白名单 ReturnAddress 的票 | + +仍建议后续处理(不在本 PR 范围):分叉生效后拒绝非创世的 3 段 `ticketId`(清除分叉前预埋的旧格式票, +这类票的 `pubHash` 校验恒被跳过);若需要链上强制“矿工批准”,新增矿工签名的批准动作; +已用创世重入伪造出来的 `IsGenesis` 票据需由运维用 force-close 清理。 diff --git a/plugin/dapp/evm/cmd/ci2/chain33.proxyminer.toml b/plugin/dapp/evm/cmd/ci2/chain33.proxyminer.toml index 1316dafbb..925e2ae1c 100644 --- a/plugin/dapp/evm/cmd/ci2/chain33.proxyminer.toml +++ b/plugin/dapp/evm/cmd/ci2/chain33.proxyminer.toml @@ -725,6 +725,8 @@ ForkStorageLocalDB=0 Enable=0 ForkTicketId=1062000 ForkTicketVrf=1770000 +# TicketOpen 的 MinerAddress 归属校验(H-2):MinerAddress 只能是签名者自己,或 ReturnAddress 通过 Tbind 授权的矿工 +ForkTicketMinerAuth=48100000 # 拒绝同一笔 tclose 交易内重复的 ticketId(重复会等价于同一张票被多次付款) ForkTicketCloseDup=0 ForkMinerExecerCheck=0 diff --git a/plugin/dapp/ticket/executor/testdata/chain33.cfg.toml b/plugin/dapp/ticket/executor/testdata/chain33.cfg.toml index e5ae2e2f2..03a7b9d5d 100644 --- a/plugin/dapp/ticket/executor/testdata/chain33.cfg.toml +++ b/plugin/dapp/ticket/executor/testdata/chain33.cfg.toml @@ -236,6 +236,7 @@ Enable=0 Enable=0 ForkTicketId = 1600000 ForkTicketVrf = 2070000 +ForkTicketMinerAuth = 0 ForkTicketCloseDup = 0 ForkMinerExecerCheck = 0 diff --git a/plugin/dapp/ticket/executor/ticket_miner_auth_test.go b/plugin/dapp/ticket/executor/ticket_miner_auth_test.go new file mode 100644 index 000000000..ca70ec5f6 --- /dev/null +++ b/plugin/dapp/ticket/executor/ticket_miner_auth_test.go @@ -0,0 +1,422 @@ +// 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 executor_test + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/33cn/chain33/account" + apimock "github.com/33cn/chain33/client/mocks" + "github.com/33cn/chain33/common" + "github.com/33cn/chain33/common/address" + "github.com/33cn/chain33/common/crypto" + dbm "github.com/33cn/chain33/common/db" + "github.com/33cn/chain33/system/dapp" + "github.com/33cn/chain33/types" + "github.com/33cn/chain33/util" + "github.com/golang/protobuf/proto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + ex "github.com/33cn/plugin/plugin/dapp/ticket/executor" + pty "github.com/33cn/plugin/plugin/dapp/ticket/types" +) + +// 审计项 H-2 的回归用例:TicketOpen 必须校验 MinerAddress 归属。 +// +// 票据的本地索引 LODB-ticket-tl:: 与共识选票都用 MinerAddress +// 来标识归属,而 MinerAddress 原本没有任何归属校验:任何签名者只要把 ReturnAddress +// 填成自己,就能把票据挂到任意矿工名下,让该矿工节点把它当自己的票挖出来,出块奖励 +// 却增发给票据的 ReturnAddress(出票人)。修复后(ForkTicketMinerAuth 生效): +// - MinerAddress 必须是合法地址; +// - MinerAddress 只能是签名者自己,或 ReturnAddress 通过 Tbind 明确授权的矿工; +// - 同时修掉 Count > len(PubHashes) 的越界 panic。 +const ( + // minerAuthHeight 高于 testdata 的 ForkTicketId(1600000),ForkTicketMinerAuth 默认 0 已生效 + minerAuthHeight = int64(1700000) + minerAuthTime = int64(1539918074) + // minerAuthLateFork 用于覆盖“分叉尚未生效”的旧路径 + minerAuthLateFork = int64(2000000) +) + +type authEnv struct { + driver dapp.Driver + db dbm.KVDB + close func() +} + +func newAuthEnv(t *testing.T, cfg *types.Chain33Config, height, blockTime int64) *authEnv { + dir, ldb, kvdb := util.CreateTestDB() + api := new(apimock.QueueProtocolAPI) + api.On("GetConfig", mock.Anything).Return(cfg, nil) + driver, err := dapp.LoadDriver("ticket", height) + require.NoError(t, err) + driver.SetAPI(api) + driver.SetEnv(height, blockTime, uint64(blockTime)) + driver.SetStateDB(kvdb) + driver.SetLocalDB(kvdb) + return &authEnv{driver: driver, db: kvdb, close: func() { util.CloseTestDB(dir, ldb) }} +} + +// fundTicketExec 把票面资金放进地址在 ticket 执行器下的子账户(等价于链上 coins->ticket) +func (e *authEnv) fundTicketExec(t *testing.T, cfg *types.Chain33Config, addr string, amount int64) { + acc := account.NewCoinsAccount(cfg) + acc.SetDB(e.db) + acc.SaveExecAccount(address.ExecAddress(pty.TicketX), &types.Account{Addr: addr, Balance: amount, Frozen: 0}) +} + +// execBalance 读回地址在 ticket 执行器下的子账户余额 +func (e *authEnv) execBalance(cfg *types.Chain33Config, addr string) *types.Account { + acc := account.NewCoinsAccount(cfg) + acc.SetDB(e.db) + return acc.LoadExecAccount(addr, address.ExecAddress(pty.TicketX)) +} + +func authAddr(priv crypto.PrivKey) string { + return address.PubKeyToAddr(address.DefaultID, priv.PubKey().Bytes()) +} + +func authTx(t *testing.T, cfg *types.Chain33Config, action string, payload proto.Message, priv crypto.PrivKey) *types.Transaction { + ety := types.LoadExecutorType("ticket") + tx, err := ety.Create(action, payload) + require.NoError(t, err) + tx, err = types.FormatTx(cfg, "ticket", tx) + require.NoError(t, err) + tx.Sign(types.SECP256K1, priv) + return tx +} + +// authPubHashes 生成 count 个与指定私钥绑定的 pubHash(开票人自己的 privHash 哈希) +func authPubHashes(priv crypto.PrivKey, count int32, seed int64) [][]byte { + hashes := make([][]byte, count) + for i := 0; i < int(count); i++ { + privHash := common.Sha256([]byte(fmt.Sprintf("%x:%d:%d", priv.Bytes(), i, seed))) + hashes[i] = common.Sha256(privHash) + } + return hashes +} + +// authNewTickets 从回执里取出新建票据的 ticketId 与归属地址 +func authNewTickets(t *testing.T, receipt *types.Receipt) (ids, addrs []string) { + for _, l := range receipt.Logs { + if l.Ty != pty.TyLogNewTicket { + continue + } + var rt pty.ReceiptTicket + require.NoError(t, types.Decode(l.Log, &rt)) + ids = append(ids, rt.TicketId) + addrs = append(addrs, rt.Addr) + } + return ids, addrs +} + +// authCfgForkAt 基于 testdata 配置,把 ForkTicketMinerAuth 改成指定高度 +func authCfgForkAt(t *testing.T, height int64) *types.Chain33Config { + data, err := os.ReadFile("testdata/chain33.cfg.toml") + require.NoError(t, err) + cfgStr := strings.Replace(string(data), "ForkTicketMinerAuth = 0", + fmt.Sprintf("ForkTicketMinerAuth = %d", height), 1) + require.NotEqual(t, string(data), cfgStr, "testdata 应显式配置 ForkTicketMinerAuth") + cfg := types.NewChain33Config(cfgStr) + require.True(t, cfg.IsDappFork(height, pty.TicketX, pty.ForkTicketMinerAuth)) + require.False(t, cfg.IsDappFork(height-1, pty.TicketX, pty.ForkTicketMinerAuth)) + return cfg +} + +// Test_TicketOpen_RejectsForeignMinerAddress 修复后第三方不能把票据挂到别的矿工名下 +func Test_TicketOpen_RejectsForeignMinerAddress(t *testing.T) { + cfg := mock33.GetAPI().GetConfig() + require.True(t, cfg.IsDappFork(minerAuthHeight, pty.TicketX, pty.ForkTicketMinerAuth), + "ForkTicketMinerAuth 默认注册为 0,应当已生效") + + attacker, err := FromPrivkey(PrivKeyA) + require.NoError(t, err) + victim, err := FromPrivkey(PrivKeyB) + require.NoError(t, err) + attackerAddr, victimAddr := authAddr(attacker), authAddr(victim) + + env := newAuthEnv(t, cfg, minerAuthHeight, minerAuthTime) + defer env.close() + + price := pty.GetTicketMinerParam(cfg, minerAuthHeight).TicketPrice + env.fundTicketExec(t, cfg, attackerAddr, 4*price) + + // 攻击者用自己的地址做 ReturnAddress,把 MinerAddress 填成受害矿工 + topen := &pty.TicketOpen{ + MinerAddress: victimAddr, + ReturnAddress: attackerAddr, + Count: 1, + RandSeed: 1, + PubHashes: authPubHashes(attacker, 1, 1), + } + tx := authTx(t, cfg, "Topen", topen, attacker) + require.Equal(t, attackerAddr, tx.From()) + + receipt, err := env.driver.Exec(tx, 0) + assert.Nil(t, receipt) + assert.Equal(t, pty.ErrMinerAddr, err) + // 校验发生在冻结之前:攻击者的票面资金没有被扣走 + assert.Equal(t, 4*price, env.execBalance(cfg, attackerAddr).GetBalance()) +} + +// Test_TicketOpen_AllowsOwnMinerAddress 自挖路径(MinerAddress = ReturnAddress = 签名者)不受影响 +func Test_TicketOpen_AllowsOwnMinerAddress(t *testing.T) { + cfg := mock33.GetAPI().GetConfig() + self, err := FromPrivkey(PrivKeyA) + require.NoError(t, err) + selfAddr := authAddr(self) + + env := newAuthEnv(t, cfg, minerAuthHeight, minerAuthTime) + defer env.close() + + price := pty.GetTicketMinerParam(cfg, minerAuthHeight).TicketPrice + env.fundTicketExec(t, cfg, selfAddr, 4*price) + + topen := &pty.TicketOpen{ + MinerAddress: selfAddr, + ReturnAddress: selfAddr, + Count: 2, + RandSeed: 7, + PubHashes: authPubHashes(self, 2, 7), + } + tx := authTx(t, cfg, "Topen", topen, self) + require.Equal(t, selfAddr, tx.From()) + + receipt, err := env.driver.Exec(tx, 0) + require.NoError(t, err) + require.NotNil(t, receipt) + assert.Equal(t, int32(types.ExecOk), receipt.Ty) + + ids, addrs := authNewTickets(t, receipt) + require.Len(t, ids, 2) + for i, id := range ids { + assert.Equal(t, selfAddr, addrs[i]) + assert.True(t, strings.HasPrefix(id, selfAddr+":"), "ticketId 前缀应为开票矿工地址: %s", id) + // 票据确实落到状态库 + var saved pty.Ticket + var found bool + for _, kv := range receipt.KV { + if string(kv.Key) == string(ex.Key(id)) { + require.NoError(t, types.Decode(kv.Value, &saved)) + found = true + } + } + assert.True(t, found, "票据应写入状态库: %s", id) + assert.Equal(t, selfAddr, saved.GetMinerAddress()) + } +} + +// Test_TicketOpen_AllowsBoundMinerAddress ReturnAddress 明确绑定(Tbind)的矿工仍可开票/被代开票 +func Test_TicketOpen_AllowsBoundMinerAddress(t *testing.T) { + cfg := mock33.GetAPI().GetConfig() + owner, err := FromPrivkey(PrivKeyA) // 票主:ReturnAddress,也是签名者 + require.NoError(t, err) + miner, err := FromPrivkey(PrivKeyB) // 被授权的矿工 + require.NoError(t, err) + ownerAddr, minerAddr := authAddr(owner), authAddr(miner) + + env := newAuthEnv(t, cfg, minerAuthHeight, minerAuthTime) + defer env.close() + + price := pty.GetTicketMinerParam(cfg, minerAuthHeight).TicketPrice + env.fundTicketExec(t, cfg, ownerAddr, 8*price) + + // 票主签名 Tbind:授权 miner 挖自己的票 + bindTx := authTx(t, cfg, "Tbind", &pty.TicketBind{MinerAddress: minerAddr, ReturnAddress: ownerAddr}, owner) + bindReceipt, err := env.driver.Exec(bindTx, 0) + require.NoError(t, err) + require.NotNil(t, bindReceipt) + require.Equal(t, int32(types.ExecOk), bindReceipt.Ty) + + // 1) 票主自己开票,MinerAddress 填被授权的矿工 + topen := &pty.TicketOpen{ + MinerAddress: minerAddr, + ReturnAddress: ownerAddr, + Count: 1, + RandSeed: 11, + PubHashes: authPubHashes(miner, 1, 11), + } + receipt, err := env.driver.Exec(authTx(t, cfg, "Topen", topen, owner), 0) + require.NoError(t, err) + require.NotNil(t, receipt) + assert.Equal(t, int32(types.ExecOk), receipt.Ty) + ids, addrs := authNewTickets(t, receipt) + require.Len(t, ids, 1) + assert.Equal(t, minerAddr, addrs[0]) + + // 2) 被授权矿工代开票(fromaddr = miner != ReturnAddress) + topen2 := &pty.TicketOpen{ + MinerAddress: minerAddr, + ReturnAddress: ownerAddr, + Count: 1, + RandSeed: 12, + PubHashes: authPubHashes(miner, 1, 12), + } + receipt2, err := env.driver.Exec(authTx(t, cfg, "Topen", topen2, miner), 0) + require.NoError(t, err) + require.NotNil(t, receipt2) + assert.Equal(t, int32(types.ExecOk), receipt2.Ty) +} + +// Test_TicketOpen_RejectsInvalidMinerAddress MinerAddress 必须是合法地址 +func Test_TicketOpen_RejectsInvalidMinerAddress(t *testing.T) { + cfg := mock33.GetAPI().GetConfig() + self, err := FromPrivkey(PrivKeyA) + require.NoError(t, err) + selfAddr := authAddr(self) + + env := newAuthEnv(t, cfg, minerAuthHeight, minerAuthTime) + defer env.close() + + price := pty.GetTicketMinerParam(cfg, minerAuthHeight).TicketPrice + env.fundTicketExec(t, cfg, selfAddr, 4*price) + + topen := &pty.TicketOpen{ + MinerAddress: "not-a-chain-address", + ReturnAddress: selfAddr, + Count: 1, + RandSeed: 3, + PubHashes: authPubHashes(self, 1, 3), + } + receipt, err := env.driver.Exec(authTx(t, cfg, "Topen", topen, self), 0) + assert.Nil(t, receipt) + require.Error(t, err) + assert.NotEqual(t, pty.ErrMinerAddr, err, "应来自地址合法性校验") + assert.NotEqual(t, pty.ErrMinerNotPermit, err) +} + +// Test_TicketOpen_RejectsPubHashesCountMismatch 修复前 Count > len(PubHashes) 会越界 panic +func Test_TicketOpen_RejectsPubHashesCountMismatch(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("TicketOpen 不应 panic: %v", r) + } + }() + + cfg := mock33.GetAPI().GetConfig() + self, err := FromPrivkey(PrivKeyA) + require.NoError(t, err) + selfAddr := authAddr(self) + + env := newAuthEnv(t, cfg, minerAuthHeight, minerAuthTime) + defer env.close() + + price := pty.GetTicketMinerParam(cfg, minerAuthHeight).TicketPrice + env.fundTicketExec(t, cfg, selfAddr, 4*price) + + topen := &pty.TicketOpen{ + MinerAddress: selfAddr, + ReturnAddress: selfAddr, + Count: 2, // PubHashes 只给 1 个 + RandSeed: 5, + PubHashes: authPubHashes(self, 1, 5), + } + receipt, err := env.driver.Exec(authTx(t, cfg, "Topen", topen, self), 0) + assert.Nil(t, receipt) + assert.Equal(t, pty.ErrOpenTicketPubHash, err) +} + +// Test_TicketOpen_LegacyPathBeforeFork 分叉高度之前仍按旧逻辑放行: +// 说明 ForkTicketMinerAuth 必须配置在安全的未来高度,升级窗口内不要撤下旧规则。 +func Test_TicketOpen_LegacyPathBeforeFork(t *testing.T) { + cfg := authCfgForkAt(t, minerAuthLateFork) + require.False(t, cfg.IsDappFork(minerAuthHeight, pty.TicketX, pty.ForkTicketMinerAuth)) + + attacker, err := FromPrivkey(PrivKeyA) + require.NoError(t, err) + victim, err := FromPrivkey(PrivKeyB) + require.NoError(t, err) + attackerAddr, victimAddr := authAddr(attacker), authAddr(victim) + + env := newAuthEnv(t, cfg, minerAuthHeight, minerAuthTime) + defer env.close() + + price := pty.GetTicketMinerParam(cfg, minerAuthHeight).TicketPrice + env.fundTicketExec(t, cfg, attackerAddr, 4*price) + + topen := &pty.TicketOpen{ + MinerAddress: victimAddr, + ReturnAddress: attackerAddr, + Count: 1, + RandSeed: 1, + PubHashes: authPubHashes(attacker, 1, 1), + } + receipt, err := env.driver.Exec(authTx(t, cfg, "Topen", topen, attacker), 0) + require.NoError(t, err) + require.NotNil(t, receipt) + assert.Equal(t, int32(types.ExecOk), receipt.Ty) + + ids, addrs := authNewTickets(t, receipt) + require.Len(t, ids, 1) + assert.Equal(t, victimAddr, addrs[0], "分叉前仍会把票挂到任意矿工名下(因此分叉高度必须留足升级窗口)") +} + +// TestForkTicketMinerAuthDefaultDisabled 本分叉默认必须关闭。 +// 链配置里没有 [fork] 段时(例如部署仓的 bityuan.toml) initForkConfig 会被跳过, +// 分叉静默按 InitFork 的注册默认值生效;若默认注册为 0,升级二进制就会立刻在所有 +// 这类链上启用本规则,既可能让历史区块重放失败,也会改变当下的开票/挖矿行为。 +// 本包 testdata 显式配了 0,所以修复相关的用例仍会在生效状态下运行。 +func TestForkTicketMinerAuthDefaultDisabled(t *testing.T) { + data, err := os.ReadFile("testdata/chain33.cfg.toml") + require.NoError(t, err) + cfgStr := string(data) + // 去掉 [fork] 段,模拟没有 fork 配置的部署链 + idx := strings.Index(cfgStr, "[fork.") + require.Greater(t, idx, 0, "testdata 应包含 [fork] 段") + cfg := types.NewChain33Config(cfgStr[:idx]) + + assert.False(t, cfg.IsDappFork(0, pty.TicketX, pty.ForkTicketMinerAuth), "默认注册值不能是 0") + assert.False(t, cfg.IsDappFork(minerAuthHeight, pty.TicketX, pty.ForkTicketMinerAuth), "未显式配置时必须保持关闭") + assert.False(t, cfg.IsDappFork(types.MaxHeight-1, pty.TicketX, pty.ForkTicketMinerAuth), "默认值应为 types.MaxHeight") + + // 对照:显式配置了高度的配置(testdata)按配置生效 + assert.True(t, mock33.GetAPI().GetConfig().IsDappFork(minerAuthHeight, pty.TicketX, pty.ForkTicketMinerAuth)) +} + +// Test_TicketOpen_SelfTbindStillInjects 固定一个已知限制:Tbind 只由 ReturnAddress(票主) +// 单方签名,矿工本人不签名,因此它不是“矿工侧批准”。攻击者自签 Tbind 把自己的 ReturnAddress +// 绑到任意矿工后,仍能通过 ForkTicketMinerAuth 的绑定分支把票挂到该矿工名下。 +// 该危害由矿工侧的选票白名单过滤兜底(见 wallet.filterTrustedTickets);链上强制“矿工批准” +// 需要新增矿工签名的 action、新分叉与钱包支持,不在本 PR 范围。 +func Test_TicketOpen_SelfTbindStillInjects(t *testing.T) { + cfg := mock33.GetAPI().GetConfig() + attacker, err := FromPrivkey(PrivKeyA) + require.NoError(t, err) + victim, err := FromPrivkey(PrivKeyB) + require.NoError(t, err) + attackerAddr, victimAddr := authAddr(attacker), authAddr(victim) + + env := newAuthEnv(t, cfg, minerAuthHeight, minerAuthTime) + defer env.close() + + price := pty.GetTicketMinerParam(cfg, minerAuthHeight).TicketPrice + env.fundTicketExec(t, cfg, attackerAddr, 4*price) + + // 1) 攻击者自签 Tbind:把自己的 ReturnAddress 绑到受害矿工 + bindTx := authTx(t, cfg, "Tbind", &pty.TicketBind{MinerAddress: victimAddr, ReturnAddress: attackerAddr}, attacker) + bindReceipt, err := env.driver.Exec(bindTx, 0) + require.NoError(t, err) + require.NotNil(t, bindReceipt) + require.Equal(t, int32(types.ExecOk), bindReceipt.Ty) + + // 2) 走绑定分支,把票挂到受害矿工名下(已知限制:仍然成功) + topen := &pty.TicketOpen{ + MinerAddress: victimAddr, + ReturnAddress: attackerAddr, + Count: 1, + RandSeed: 1, + PubHashes: authPubHashes(attacker, 1, 1), + } + receipt, err := env.driver.Exec(authTx(t, cfg, "Topen", topen, attacker), 0) + require.NoError(t, err, "已知限制:自签 Tbind 后可把票挂到任意矿工名下") + require.NotNil(t, receipt) + ids, addrs := authNewTickets(t, receipt) + require.Len(t, ids, 1) + assert.Equal(t, victimAddr, addrs[0], "票据仍以受害矿工为 MinerAddress") +} diff --git a/plugin/dapp/ticket/executor/ticket_miner_inject_poc_test.go b/plugin/dapp/ticket/executor/ticket_miner_inject_poc_test.go index 7ffb4e60e..8b4373cf3 100644 --- a/plugin/dapp/ticket/executor/ticket_miner_inject_poc_test.go +++ b/plugin/dapp/ticket/executor/ticket_miner_inject_poc_test.go @@ -5,12 +5,17 @@ // 本文件是审计项 H-2(TicketOpen 不校验 MinerAddress 归属 -> 向任意矿工注入票据) // 的可执行 PoC,配合 docs/security/ticket-topen-mineraddress-injection-analysis.md 阅读。 // +// H-2 已由 dapp 分叉 ticket.ForkTicketMinerAuth 修复(见该文档 §8)。testdata 里该分叉 +// 配为 0(修复默认生效),因此本文件中演示“注入”的 PoC 用例都显式把 +// ForkTicketMinerAuth 配到未来高度(pocCfgBeforeMinerAuth),固定修复前的旧行为; +// 分叉生效后的拒绝路径与放行路径见 ticket_miner_auth_test.go。 +// // 结论概览: -// 1. Test_TicketOpen_InjectsTicketsToArbitraryMiner +// 1. Test_TicketOpen_InjectsTicketsToArbitraryMiner(分叉前) // 攻击者用自己的私钥签名,只要 ReturnAddress = 自己,就能把票据挂到任意 // MinerAddress(受害矿工)名下;票据状态索引 LODB-ticket-tl::1 // 也写在受害矿工名下,因此受害节点的钱包/矿工会把注入票据当成自己的票据。 -// 2. Test_InjectedTicket_NotMinableByVictim_PostFork +// 2. Test_InjectedTicket_NotMinableByVictim_PostFork(ForkTicketId 生效、MinerAuth 未生效) // ForkTicketId 生效后,ticketId 中的 pubHash 由开票人私钥派生;受害矿工用它 // 自己的私钥算出的 privHash 无法通过 TicketMiner 的校验(ErrCheckPubHash), // 即注入只能造成“矿工反复挖出废块”的拒绝服务,无法直接盗取该票据奖励。 @@ -21,18 +26,20 @@ // 立刻替攻击者挖票、奖励增发给攻击者 ReturnAddress。现在由 chain33 框架层 // (DriverBase.Exec 的 checkGenesisHeight + ForkGenesisOnlyAtHeightZero)拒绝 // 高度 != 0 的 Genesis,同时高度 0 的正常创世路径不受影响。 -// 4. Test_InjectedTicket_MinedByVictim_PreFork_RewardGoesToAttacker +// 4. Test_InjectedTicket_MinedByVictim_PreFork_RewardGoesToAttacker(分叉前) // ForkTicketId 未生效(或旧格式 3 段 ticketId)时,受害矿工可以成功挖出注入 // 票据,而出块奖励(MinerValue)会增发到注入者控制的 ReturnAddress 上。 -// 5. Test_TicketOpen_CountExceedsPubHashesPanic +// 5. Test_TicketOpen_CountExceedsPubHashesRejected // 同一函数内的相邻缺陷:ForkTicketId 生效后只检查 len(PubHashes)==0, // 之后按 Count 下标访问 PubHashes,Count > len(PubHashes) 会 panic // (被 DriverBase.Exec 的 recover 兜住,返回 ErrActionNotSupport)。 +// 该修复不设分叉保护,修复后直接返回 ErrOpenTicketPubHash,本用例改为回归断言。 package executor_test import ( "encoding/hex" "fmt" + "os" "strconv" "strings" "testing" @@ -60,16 +67,22 @@ const ( pocPostForkHeight = int64(1700000) pocPreForkHeight = int64(100) pocBlockTime = int64(1539918074) + // pocMinerAuthFuture 是演示“修复前”行为时给 ForkTicketMinerAuth 配置的未来高度 + pocMinerAuthFuture = int64(48100000) ) type pocEnv struct { driver dapp.Driver db dbm.KVDB + cfg *types.Chain33Config close func() } func newPocEnv(t *testing.T, height int64) *pocEnv { - cfg := mock33.GetAPI().GetConfig() + return newPocEnvWithCfg(t, mock33.GetAPI().GetConfig(), height) +} + +func newPocEnvWithCfg(t *testing.T, cfg *types.Chain33Config, height int64) *pocEnv { dir, ldb, kvdb := util.CreateTestDB() api := new(apimock.QueueProtocolAPI) api.On("GetConfig", mock.Anything).Return(cfg, nil) @@ -79,14 +92,28 @@ func newPocEnv(t *testing.T, height int64) *pocEnv { driver.SetEnv(height, pocBlockTime, uint64(pocBlockTime)) driver.SetStateDB(kvdb) driver.SetLocalDB(kvdb) - return &pocEnv{driver: driver, db: kvdb, close: func() { util.CloseTestDB(dir, ldb) }} + return &pocEnv{driver: driver, db: kvdb, cfg: cfg, close: func() { util.CloseTestDB(dir, ldb) }} +} + +// pocCfgBeforeMinerAuth 返回把 ForkTicketMinerAuth 配到未来高度的配置: +// 用于固定“分叉生效前”的旧行为。testdata 里该分叉为 0,直接用全局配置会走修复后的分支。 +func pocCfgBeforeMinerAuth(t *testing.T) *types.Chain33Config { + data, err := os.ReadFile("testdata/chain33.cfg.toml") + require.NoError(t, err) + cfgStr := strings.Replace(string(data), "ForkTicketMinerAuth = 0", + fmt.Sprintf("ForkTicketMinerAuth = %d", pocMinerAuthFuture), 1) + require.NotEqual(t, string(data), cfgStr, "testdata 应显式配置 ForkTicketMinerAuth") + cfg := types.NewChain33Config(cfgStr) + require.False(t, cfg.IsDappFork(pocPostForkHeight, pty.TicketX, pty.ForkTicketMinerAuth), + "本用例固定的是 ForkTicketMinerAuth 生效前的行为") + return cfg } // fundCoins 给地址预置 coins 余额,等价于攻击者自己持有买票的本金。 // 真实链上买票的第一步是把币转到 ticket 执行器地址(exec 子账户), // 这里直接写入 ticket 执行器的 exec 子账户。 func (e *pocEnv) fundCoins(t *testing.T, addr string, amount int64) { - acc := account.NewCoinsAccount(mock33.GetAPI().GetConfig()) + acc := account.NewCoinsAccount(e.cfg) acc.SetDB(e.db) acc.SaveExecAccount(address.ExecAddress(pty.TicketX), &types.Account{Addr: addr, Balance: amount, Frozen: 0}) } @@ -128,9 +155,12 @@ func ticketIDsFromLogs(t *testing.T, receipt *types.Receipt) (ids []string) { } // openInjectedTicket 由攻击者签名开票:MinerAddress=受害矿工,ReturnAddress=攻击者自己。 -// withPubHash 对应 ForkTicketId 是否生效。 +// withPubHash 对应 ForkTicketId 是否生效。env 必须使用“ForkTicketMinerAuth 尚未生效”的配置, +// 否则修复后的归属校验会先返回 ErrMinerAddr(见 ticket_miner_auth_test.go)。 func openInjectedTicket(t *testing.T, env *pocEnv, height int64, attackerPriv, victimPriv crypto.PrivKey, count int32, withPubHash bool) (ids []string, tx *types.Transaction, receipt *types.Receipt, seed int64) { - cfg := mock33.GetAPI().GetConfig() + cfg := env.cfg + require.False(t, cfg.IsDappFork(height, pty.TicketX, pty.ForkTicketMinerAuth), + "本 PoC 固定的是 ForkTicketMinerAuth 生效前的行为") attackerAddr := pocAddress(attackerPriv) victimAddr := pocAddress(victimPriv) price := pty.GetTicketMinerParam(cfg, height).TicketPrice @@ -184,9 +214,10 @@ func openInjectedTicket(t *testing.T, env *pocEnv, height int64, attackerPriv, v return ids, tx, receipt, seed } -// Test_TicketOpen_InjectsTicketsToArbitraryMiner 证明“向任意矿工注入票据”本身可以实现: +// Test_TicketOpen_InjectsTicketsToArbitraryMiner 证明“向任意矿工注入票据”在修复前可以实现: // 攻击者不持有受害矿工私钥,也能让受害矿工地址名下出现票据,且官方 TicketList 查询 -// 会把这些票据返回给受害地址。 +// 会把这些票据返回给受害地址。修复(ForkTicketMinerAuth)生效后该路径返回 ErrMinerAddr, +// 回归见 Test_TicketOpen_RejectsForeignMinerAddress。 func Test_TicketOpen_InjectsTicketsToArbitraryMiner(t *testing.T) { attackerPriv, err := FromPrivkey(PrivKeyA) assert.NoError(t, err) @@ -194,7 +225,8 @@ func Test_TicketOpen_InjectsTicketsToArbitraryMiner(t *testing.T) { assert.NoError(t, err) attackerAddr, victimAddr := pocAddress(attackerPriv), pocAddress(victimPriv) - env := newPocEnv(t, pocPostForkHeight) + // 固定修复前的旧行为:把 ForkTicketMinerAuth 配到未来高度 + env := newPocEnvWithCfg(t, pocCfgBeforeMinerAuth(t), pocPostForkHeight) defer env.close() const count = int32(3) @@ -226,17 +258,18 @@ func Test_TicketOpen_InjectsTicketsToArbitraryMiner(t *testing.T) { t.Logf("injected %d tickets, victim=%s attacker=%s", len(listed), victimAddr, attackerAddr) } -// Test_InjectedTicket_NotMinableByVictim_PostFork:ForkTicketId 生效时, -// pubHash 把票据绑到开票人私钥,受害矿工无法挖出注入票据(只能造成废块/拒绝服务)。 +// Test_InjectedTicket_NotMinableByVictim_PostFork:ForkTicketId 生效(而 ForkTicketMinerAuth +// 尚未生效,即 v1.76.x 现网状态)时,pubHash 把票据绑到开票人私钥,受害矿工无法挖出注入 +// 票据(只能造成废块/拒绝服务)。 func Test_InjectedTicket_NotMinableByVictim_PostFork(t *testing.T) { - cfg := mock33.GetAPI().GetConfig() + cfg := pocCfgBeforeMinerAuth(t) attackerPriv, err := FromPrivkey(PrivKeyA) assert.NoError(t, err) victimPriv, err := FromPrivkey(PrivKeyB) assert.NoError(t, err) attackerAddr, victimAddr := pocAddress(attackerPriv), pocAddress(victimPriv) - env := newPocEnv(t, pocPostForkHeight) + env := newPocEnvWithCfg(t, cfg, pocPostForkHeight) defer env.close() ids, _, _, seed := openInjectedTicket(t, env, pocPostForkHeight, attackerPriv, victimPriv, 1, true) @@ -279,14 +312,14 @@ func Test_InjectedTicket_NotMinableByVictim_PostFork(t *testing.T) { // ForkTicketId 未生效(旧格式 3 段 ticketId)时,受害矿工能挖出注入票据, // 而出块奖励增发到注入者的 ReturnAddress。 func Test_InjectedTicket_MinedByVictim_PreFork_RewardGoesToAttacker(t *testing.T) { - cfg := mock33.GetAPI().GetConfig() + cfg := pocCfgBeforeMinerAuth(t) attackerPriv, err := FromPrivkey(PrivKeyA) assert.NoError(t, err) victimPriv, err := FromPrivkey(PrivKeyB) assert.NoError(t, err) attackerAddr, victimAddr := pocAddress(attackerPriv), pocAddress(victimPriv) - env := newPocEnv(t, pocPreForkHeight) + env := newPocEnvWithCfg(t, cfg, pocPreForkHeight) defer env.close() ids, _, _, _ := openInjectedTicket(t, env, pocPreForkHeight, attackerPriv, victimPriv, 1, false) @@ -392,25 +425,28 @@ func Test_TicketGenesisAllowedAtHeightZero(t *testing.T) { assert.Len(t, ticketIDsFromLogs(t, receipt), 1) } -// Test_TicketOpen_CountExceedsPubHashesPanic:同一函数内的相邻缺陷。 -// ForkTicketId 生效时只拒绝 len(PubHashes)==0,却按 Count 下标取值, -// Count > len(PubHashes) 直接 panic(链上执行路径会被 recover 成 ErrExecPanic)。 -func Test_TicketOpen_CountExceedsPubHashesPanic(t *testing.T) { +// Test_TicketOpen_CountExceedsPubHashesRejected:同一函数内的相邻缺陷(修复后的回归用例)。 +// ForkTicketId 生效时只拒绝 len(PubHashes)==0,却按 Count 下标取值,Count > len(PubHashes) +// 曾直接 panic(被 DriverBase.Exec 的 recover 兜成 ErrActionNotSupport);修复后按 Count +// 校验,直接返回 ErrOpenTicketPubHash。该修复不设分叉保护:panic 交易会被 PreExecBlock +// 从区块中丢弃,从未写入任何区块,因此不影响历史区块重放。 +func Test_TicketOpen_CountExceedsPubHashesRejected(t *testing.T) { attackerPriv, err := FromPrivkey(PrivKeyA) - assert.NoError(t, err) - victimPriv, err := FromPrivkey(PrivKeyB) - assert.NoError(t, err) + require.NoError(t, err) env := newPocEnv(t, pocPostForkHeight) defer env.close() - attackerAddr, victimAddr := pocAddress(attackerPriv), pocAddress(victimPriv) - cfg := mock33.GetAPI().GetConfig() + attackerAddr := pocAddress(attackerPriv) + cfg := env.cfg + require.True(t, cfg.IsDappFork(pocPostForkHeight, pty.TicketX, "ForkTicketId"), + "本用例要求 ForkTicketId 已生效") price := pty.GetTicketMinerParam(cfg, pocPostForkHeight).TicketPrice env.fundCoins(t, attackerAddr, 2000*price) + // MinerAddress 用签名者自己,先通过归属校验,单独暴露 PubHashes 长度校验 topen := &pty.TicketOpen{ - MinerAddress: victimAddr, + MinerAddress: attackerAddr, ReturnAddress: attackerAddr, Count: 1000, RandSeed: 1, @@ -419,19 +455,20 @@ func Test_TicketOpen_CountExceedsPubHashesPanic(t *testing.T) { tx := createTicketTx(t, "Topen", topen, attackerPriv) ticket, ok := env.driver.(*ex.Ticket) - assert.True(t, ok) + require.True(t, ok) action := ex.NewAction(ticket, tx) + // 修复后不得再 panic var recovered interface{} + var openErr error func() { defer func() { recovered = recover() }() - _, _ = action.TicketOpen(topen) + _, openErr = action.TicketOpen(topen) }() - assert.NotNil(t, recovered, "Count(1000) > len(PubHashes)(1) 必须触发 panic") - t.Logf("TicketOpen panic: %v", recovered) + assert.Nil(t, recovered, "修复后 Count(1000) > len(PubHashes)(1) 不得再 panic") + assert.Equal(t, pty.ErrOpenTicketPubHash, openErr) - // 链上执行路径(DriverBase.Exec)会把 panic 转成 ErrActionNotSupport, - // 区块执行路径(executor.procExecAddBlock)则转成 types.ErrExecPanic + // 链上执行路径(DriverBase.Exec)同样返回 ErrOpenTicketPubHash,而不是 ErrActionNotSupport _, err = env.driver.Exec(tx, 0) - assert.Equal(t, types.ErrActionNotSupport, err) + assert.Equal(t, pty.ErrOpenTicketPubHash, err) } diff --git a/plugin/dapp/ticket/executor/ticketdb.go b/plugin/dapp/ticket/executor/ticketdb.go index be29b6766..f82ec79aa 100644 --- a/plugin/dapp/ticket/executor/ticketdb.go +++ b/plugin/dapp/ticket/executor/ticketdb.go @@ -235,6 +235,21 @@ func (action *Action) TicketBind(tbind *ty.TicketBind) (*types.Receipt, error) { // TicketOpen ticket open func (action *Action) TicketOpen(topen *ty.TicketOpen) (*types.Receipt, error) { chain33Cfg := action.api.GetConfig() + //MinerAddress 是票据的归属标签: 本地索引、钱包取票、共识选票都按它来。 + //不校验归属时, 任何签名者只要把 ReturnAddress 填成自己, 就能把票据挂到任意 + //矿工名下(该矿工节点会把它当自己的票去挖, 奖励却记在出票人的 ReturnAddress)。 + if chain33Cfg.IsDappFork(action.height, ty.TicketX, ty.ForkTicketMinerAuth) { + if err := address.CheckAddress(topen.MinerAddress, action.height); err != nil { + tlog.Error("TicketOpen.CheckMinerAddress", "miner", topen.MinerAddress, "err", err) + return nil, err + } + bindMiner := action.getBind(topen.ReturnAddress) + if topen.MinerAddress != action.fromaddr && topen.MinerAddress != bindMiner { + tlog.Error("TicketOpen.MinerNotOwned", "miner", topen.MinerAddress, "from", action.fromaddr, + "return", topen.ReturnAddress, "bind", bindMiner) + return nil, ty.ErrMinerAddr + } + } prefix := common.ToHex(action.txhash) prefix = topen.MinerAddress + ":" + prefix + ":" var logs []*types.ReceiptLog @@ -255,7 +270,8 @@ func (action *Action) TicketOpen(topen *ty.TicketOpen) (*types.Receipt, error) { id := prefix + fmt.Sprintf("%010d", i) //add pubHash if chain33Cfg.IsDappFork(action.height, ty.TicketX, "ForkTicketId") { - if len(topen.PubHashes) == 0 { + //PubHashes 必须覆盖 Count, 否则下面按下标取值会越界 panic + if len(topen.PubHashes) < int(topen.Count) { return nil, ty.ErrOpenTicketPubHash } id = id + ":" + fmt.Sprintf("%x:%d", topen.PubHashes[i], topen.RandSeed) diff --git a/plugin/dapp/ticket/rpc/rpc_test.go b/plugin/dapp/ticket/rpc/rpc_test.go index b0801485b..18e56e04d 100755 --- a/plugin/dapp/ticket/rpc/rpc_test.go +++ b/plugin/dapp/ticket/rpc/rpc_test.go @@ -286,6 +286,8 @@ Enable=0 Enable=0 ForkTicketId =0 ForkTicketVrf =0 +# TicketOpen 的 MinerAddress 归属校验(H-2):MinerAddress 只能是签名者自己,或 ReturnAddress 通过 Tbind 授权的矿工 +ForkTicketMinerAuth =0 ForkTicketCloseDup =0 ForkMinerExecerCheck=0 diff --git a/plugin/dapp/ticket/types/ticket.go b/plugin/dapp/ticket/types/ticket.go index 829923378..27415a50e 100755 --- a/plugin/dapp/ticket/types/ticket.go +++ b/plugin/dapp/ticket/types/ticket.go @@ -66,6 +66,13 @@ const ForkMinerExecerCheck = "ForkMinerExecerCheck" // TicketCountOpenOnce count open once const TicketCountOpenOnce = 1000 +// ForkTicketMinerAuth 分叉后 TicketOpen 必须校验 MinerAddress 归属: +// MinerAddress 只能是签名者自己,或 ReturnAddress 通过 Tbind 明确授权的矿工。 +// 票据的本地索引(LODB-ticket-tl::)与共识选票都挂在 +// MinerAddress 上,不校验归属时第三方可以把票据挂到任意矿工名下,让该矿工节点 +// 把它当成自己的票据去挖,从而把出块奖励送给票据的 ReturnAddress。 +const ForkTicketMinerAuth = "ForkTicketMinerAuth" + // ErrOpenTicketPubHash err type var ErrOpenTicketPubHash = errors.New("ErrOpenTicketPubHash") @@ -88,6 +95,11 @@ func InitFork(cfg *types.Chain33Config) { cfg.RegisterDappFork(TicketX, "Enable", 0) cfg.RegisterDappFork(TicketX, "ForkTicketId", 0) cfg.RegisterDappFork(TicketX, "ForkTicketVrf", 0) + // 默认关闭: 链配置里没有 [fork] 段时(例如部署仓的 bityuan.toml) initForkConfig 会被跳过, + // 分叉按这里的注册默认值生效。这类链若在升级时默认启用本分叉, 历史区块重放与当下挖矿 + // 行为都会被立即改变; 因此默认必须为 MaxHeight(关闭), 由 [fork.sub.ticket] + // ForkTicketMinerAuth 显式指定激活高度后再启用。 + cfg.RegisterDappFork(TicketX, ForkTicketMinerAuth, types.MaxHeight) cfg.RegisterDappFork(TicketX, ForkTicketCloseDup, 0) cfg.RegisterDappFork(TicketX, ForkMinerExecerCheck, 0) } diff --git a/plugin/dapp/ticket/wallet/exec.go b/plugin/dapp/ticket/wallet/exec.go index 4739ea47b..e08c97a5a 100644 --- a/plugin/dapp/ticket/wallet/exec.go +++ b/plugin/dapp/ticket/wallet/exec.go @@ -29,6 +29,8 @@ func (policy *ticketPolicy) On_CloseTickets(req *ty.TicketClose) (types.Message, // On_WalletGetTickets get ticket func (policy *ticketPolicy) On_WalletGetTickets(req *types.ReqNil) (types.Message, error) { tickets, privs, err := policy.getTicketsByStatus(1) + // 共识选票会直接用这里的结果,按 ReturnAddress 白名单过滤掉别人挂到本矿工名下的票 + tickets = filterTrustedTickets(tickets) tks := &ty.ReplyWalletTickets{Tickets: tickets, Privkeys: privs} return tks, err } diff --git a/plugin/dapp/ticket/wallet/ticket.go b/plugin/dapp/ticket/wallet/ticket.go index bce6125ba..cf0301ab9 100644 --- a/plugin/dapp/ticket/wallet/ticket.go +++ b/plugin/dapp/ticket/wallet/ticket.go @@ -472,6 +472,40 @@ func (policy *ticketPolicy) closeTickets(priv crypto.PrivKey, addressID int32, i return policy.getWalletOperate().SendTransaction(ta, []byte(ty.TicketX), priv, addressID, "") } +// filterTrustedTickets 只保留 ReturnAddress 受信的票据。 +// +// 票据的 MinerAddress 是公开可写的归属标签:第三方可以签一笔 Tbind 把自己的 +// ReturnAddress 绑到本矿工,再把票挂到本矿工名下(执行器层的 ForkTicketMinerAuth +// 只能挡住“未绑定”的裸挂)。本节点若把这些票挖出来,出块奖励会增发给票的 +// ReturnAddress 而不是本矿工,等于替别人出块。 +// +// 这里在选票入口做本地过滤:受信 = 自挖(MinerAddress == ReturnAddress), +// 或 ReturnAddress 命中 wallet.sub.ticket.minerwhitelist —— 该白名单是矿工本地的 +// 显式批准名单,只有名单内的 ReturnAddress 才能使用本矿工的挖矿身份。 +// 默认 minerwhitelist=["*"] 时不做任何过滤,保持原有行为。 +// +// 该过滤只影响本节点的选票(挖矿),不改变链上规则:票据仍然存在、仍可查询, +// 只是本节点不再消耗算力去挖不属于自己的票。 +func filterTrustedTickets(tickets []*ty.Ticket) []*ty.Ticket { + if len(tickets) == 0 { + return tickets + } + trusted := tickets[:0] + for _, ticket := range tickets { + if ticket == nil { + continue + } + if ticket.GetMinerAddress() == ticket.GetReturnAddress() || + checkMinerWhiteList(ticket.GetReturnAddress()) { + trusted = append(trusted, ticket) + continue + } + bizlog.Warn("skipUntrustedTicket", "ticketId", ticket.GetTicketId(), + "miner", ticket.GetMinerAddress(), "return", ticket.GetReturnAddress()) + } + return trusted +} + func (policy *ticketPolicy) getTicketsByStatus(status int32) ([]*ty.Ticket, [][]byte, error) { operater := policy.getWalletOperate() accounts, err := operater.GetWalletAccounts() diff --git a/plugin/dapp/ticket/wallet/ticket_test.go b/plugin/dapp/ticket/wallet/ticket_test.go index 0164d1a9c..52cb9d8cf 100644 --- a/plugin/dapp/ticket/wallet/ticket_test.go +++ b/plugin/dapp/ticket/wallet/ticket_test.go @@ -285,3 +285,32 @@ func (_m *walletOperateMock) WaitTxs(hashes [][]byte) []*types.TransactionDetail func (_m *walletOperateMock) GetCoinType() uint32 { return 0 } + +// TestFilterTrustedTickets 选票入口必须按 ReturnAddress 白名单过滤: +// 别人挂到本矿工名下的票不能进入共识选票,否则本节点会替票的 ReturnAddress 出块。 +func TestFilterTrustedTickets(t *testing.T) { + old := minerAddrWhiteList + defer func() { minerAddrWhiteList = old }() + + tickets := []*ty.Ticket{ + {TicketId: "self:ticket", MinerAddress: "miner", ReturnAddress: "miner"}, + {TicketId: "bound:ticket", MinerAddress: "miner", ReturnAddress: "cold"}, + {TicketId: "injected:ticket", MinerAddress: "miner", ReturnAddress: "attacker"}, + } + + // 默认 ["*"]:不过滤,保持原有行为 + minerAddrWhiteList = map[string]bool{"*": true} + assert.Len(t, filterTrustedTickets(tickets), 3) + + // 显式白名单:只保留自挖票和白名单冷地址的票 + minerAddrWhiteList = map[string]bool{"cold": true} + got := filterTrustedTickets(tickets) + if assert.Len(t, got, 2) { + assert.Equal(t, "self:ticket", got[0].TicketId) + assert.Equal(t, "bound:ticket", got[1].TicketId) + } + + // 空列表与 nil 元素安全 + assert.Empty(t, filterTrustedTickets(nil)) + assert.Empty(t, filterTrustedTickets([]*ty.Ticket{nil})) +}