Skip to content

fix(privacy): enforce amount conservation in privacy tx CheckTx behind ForkPrivacyAmountCheck - #1309

Open
33cn wants to merge 3 commits into
masterfrom
fix-privacy-conservation
Open

fix(privacy): enforce amount conservation in privacy tx CheckTx behind ForkPrivacyAmountCheck#1309
33cn wants to merge 3 commits into
masterfrom
fix-privacy-conservation

Conversation

@33cn

@33cn 33cn commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Vulnerabilities

Three unlimited-minting vulnerabilities in the privacy dapp, all in plugin/dapp/privacy/executor/privacy.go CheckTx:

  1. Public2Privacy mints arbitrary UTXOs (privacy.go:216-218): CheckTx returned nil directly for ActionPublic2Privacy. payload.Amount (actually withdrawn from the public balance) and output.Keyoutput[].Amount (the UTXO denominations written to state) were never checked for conservation. An attacker could deposit 1 coin, declare UTXOs of any denomination, then cash out via privacy2public — net minting of arbitrary coins.
  2. No KeyOutput.Amount > 0 check on any path: a negative output offsets totalOutput, defeating the only implicit conservation check — the privacy2privacy fee check at privacy.go:285-295 (fee = totalInput - totalOutput >= 1 coin) — allowing huge UTXOs to be minted out of thin air.
  3. Conservation branch excludes token/para assets (privacy.go:274): the !IsPara() && (assertExec == "" || assertExec == coinExec) condition means token assets and parallel chains skip the fee/conservation check entirely, so outputs can exceed inputs arbitrarily.

Root cause

CheckTx never validated amount conservation between inputs, outputs and the declared public deposit/withdrawal amounts, and the single fee-difference check was scoped to main-chain coins only and was bypassable with negative outputs.

Fix

All new checks are gated behind a new dapp fork ForkPrivacyAmountCheck (registered in plugin/dapp/privacy/types at height 0 via RegisterDappFork, gated with cfg.IsDappFork, following the ForkEVMFixOverflow pattern). Pre-fork behavior is fully preserved for running-chain consensus compatibility.

After the fork activates:

  • public2privacy: sum(output.Keyoutput[].Amount) must equal payload.Amount, otherwise types.ErrAmount.
  • all actions: every KeyInput/KeyOutput amount must be > 0 and <= types.MaxCoin * coinPrecision (with total-output overflow guard), otherwise types.ErrAmount.
  • privacy2privacy / privacy2public: enforced for all asset types (coins, token, para chains): sum(input) >= sum(output) + fee; for privacy2public sum(input) >= sum(output) + amount + fee, otherwise pty.ErrPrivacyTxFeeNotEnough. The privacy2public withdrawal amount itself must also be positive and within range.

Tests

New plugin/dapp/privacy/executor/vuln_fix_test.go:

  • TestFixVuln_Public2Privacy_AmountInflation: deposit 1 coin / declare 1001 coin outputs — now rejected with ErrAmount, no forged UTXO in state.
  • TestFixVuln_Privacy2Privacy_NegativeOutput: negative-output offset attack rejected with ErrAmount; oversized output without offset rejected with ErrPrivacyTxFeeNotEnough.
  • TestFixVuln_TokenPrivacy2Privacy_NoConservation: token privacy2privacy with 1-token input / 1,000,000-token output rejected.
  • TestFix_LegitPrivacyFlows: legitimate public2privacy / privacy2privacy / privacy2public flows still pass CheckTx+Exec, exact balances verified.
  • TestFix_ForkGate: with the fork set to a future height, the old permissive behavior is preserved.

go test -ldflags=-checklinkname=0 ./plugin/dapp/privacy/... — all packages pass, including the pre-existing executor/wallet/rpc regression tests.

king added 3 commits August 31, 2026 10:55
…d ForkPrivacyAmountCheck

Fix three unlimited minting vulnerabilities in the privacy dapp:

1. ActionPublic2Privacy CheckTx returned nil directly, never verifying
   that the declared UTXO outputs sum to payload.Amount actually
   withdrawn from the public balance, allowing an attacker to deposit
   1 coin and mint arbitrarily large UTXOs, then cash out via
   privacy2public.
2. No positivity/range check on KeyOutput.Amount anywhere: a negative
   output offset totalOutput and defeated the only implicit
   conservation check (fee = totalInput - totalOutput >= 1 coin).
3. The fee/conservation branch only applied to main-chain coins,
   excluding token assets and parallel chains from any conservation
   check.

After ForkPrivacyAmountCheck activates (registered at height 0, gated
via cfg.IsDappFork; pre-fork behavior is unchanged for running-chain
consensus compatibility):
- public2privacy requires sum(output.Amount) == payload.Amount
- every KeyInput/KeyOutput amount must be > 0 and <= MaxCoin*precision
- privacy2privacy/privacy2public enforce sum(input) >= sum(output) +
  fee (privacy2public: sum(input) >= sum(output) + amount + fee) for
  all asset types, including token and para chains

Add vuln_fix_test.go regression tests covering the three attack
scenarios (all rejected post-fork), legitimate flows (unaffected),
and the fork gate (pre-fork behavior preserved).
RegisterDappFork requires every registered dapp fork to have an entry
in the toml [fork.sub.<exec>] section for non-local configs, otherwise
Chain33Config init panics with 'exec privacy name ForkPrivacyAmountCheck
not config in config file', crashing node startup on parachain CI jobs
(ci_paracross, ci_parachain_rollup, ci_rgbx, ci_cross2eth).
…ervation check

The fork-gated conservation check required totalInput >= totalOutput +
1 coin for all asset types, but the wallet only burns a UTXO fee for
main-chain coins (isMainetCoins); for token assets and para chains
utxoBurnedAmount is 0 and legitimate txs conserve exactly
(totalInput == totalOutput, fee paid via tx.Fee from the public
account). This broke legitimate token/para privacy2privacy transfers
(ci_paracross token GD priv2priv case).

Make the required UTXO-burned fee conditional, mirroring both the
wallet construction and the existing coins-only fee branch: 1 coin for
main-chain coins, 0 otherwise. The core invariant is unchanged: outputs
never exceed inputs for any asset type.

Add a legit token privacy2privacy regression case (input sum == output
sum, zero utxo fee) that must pass CheckTx+Exec.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant