fix(core/bitcoin): Reject new external outputs in bitcoin replacement transactions.
What changed, and why it matters
This update fixes a security gap in how Trezor handles Bitcoin 'replacement transactions' (used to speed up or adjust a pending payment). Before the fix, a non-payjoin replacement could silently add a brand-new external recipient output, potentially redirecting some of the user's funds without asking for on-device confirmation. The patch now rejects any new external output in non-payjoin replacement transactions, so the device will block such changes and show an error instead of signing.
Treat this as a security fix and include it in the next firmware release. Users who sign Bitcoin replacement transactions (e.g., fee bumps) should upgrade. Wallet software/host integrations that construct replacement transactions should ensure they do not introduce new external outputs unless operating in a validated payjoin flow.
Security signals we found
Missing authorization / confirmation bypass for new external outputs in replacement transactions
Potential fund redirection via output manipulation in RBF/bumped transactions
Addition of defensive validation with explicit error raise
New regression test named as an attack scenario
Evidence from the diff
In core/src/apps/bitcoin/sign_tx/approvers.py, BasicApprover.add_external_output() previously allowed new external outputs (those without an orig_hash/orig_index) during replacement transactions as long as they were not OP_RETURN. For non-payjoin replacements this meant an attacker or malicious host could add an extra output, decreasing another output to fund it, without user confirmation. The patch restructures the logic so that when orig_txo is absent and self.orig_total_in is set (i.e., a replacement transaction), any new non-OP_RETURN external output in a non-payjoin flow raises ProcessError(‘Adding new external outputs in replacement transactions is not supported.’). Payjoin replacements retain the existing behavior because their input/output invariants are validated separately. A regression test test_attack_add_external_output_in_replacement demonstrates the blocked attack.
Changed components
Trezor Core firmwareapps/bitcoin/sign_tx/approvers.pyBasicApprover.add_external_output()Bitcoin transaction signing flow for replacement/RBF transactionsInspect captured patch +95 / −15
diff --git a/core/.changelog.d/+rbf.security b/core/.changelog.d/+rbf.security
new file mode 100644
index 00000000..e49d0829
--- /dev/null
+++ b/core/.changelog.d/+rbf.security
@@ -0,0 +1 @@
+Reject new external outputs in bitcoin replacement transactions.
diff --git a/core/src/apps/bitcoin/sign_tx/approvers.py b/core/src/apps/bitcoin/sign_tx/approvers.py
index 0459de49..ce464bfd 100644
--- a/core/src/apps/bitcoin/sign_tx/approvers.py
+++ b/core/src/apps/bitcoin/sign_tx/approvers.py
@@ -202,42 +202,62 @@ class BasicApprover(Approver):
) -> None:
await super().add_external_output(txo, script_pubkey, tx_info, orig_txo)
+ # This function handles the confirmation logic relating to replacement transactions, which
+ # allow users to make modifications to transactions that they already signed without having
+ # to re-approve the entire transaction again. This is useful for fee bumps or payjoins.
+ #
+ # As a general rule for replacement transactions, approve_tx() ensures that any increase in
+ # the amount the user is spending goes towards the mining fee.
+ #
+ # A payjoin is a replacement transaction that increases the amount supplied by external
+ # inputs. Payjoins allow increasing existing external outputs and adding new ones without
+ # confirmation, while decreasing external outputs is not allowed. The combination of this
+ # no-decrease rule and approve_tx()'s spending invariant ensures that any increase to
+ # external outputs is paid for by new external inputs rather than by the user. Without the
+ # no-decrease rule, one external output's decrease could silently fund another's increase.
+ #
+ # Non-payjoin replacement transactions are only allowed to decrease the value of external
+ # outputs. This may be needed to bump the mining fee if the original transaction transfers
+ # the entire account balance, aka "Send Max".
+
if orig_txo:
+ # This is an external output in a replacement transaction, referencing an original output.
if txo.amount < orig_txo.amount:
- # Replacement transactions may need to decrease the value of external outputs to
- # bump the fee. This is needed if the original transaction transfers the entire
- # account balance ("Send Max").
+ # Payjoin transactions must not decrease external outputs. See top comment.
if self.is_payjoin():
- # In case of PayJoin the above could be used to increase other external
- # outputs, which would create too much UI complexity.
raise ProcessError(
"Reducing original output amounts is not supported."
)
+
+ # Non-payjoin replacement transactions are allowed to decrease external outputs
+ # upon user confirmation. See top comment.
await helpers.confirm_modify_output(
txo, orig_txo, self.coin, self.amount_unit
)
elif txo.amount > orig_txo.amount:
- # PayJoin transactions may increase the value of external outputs without
- # confirmation, because approve_tx() together with the branch above ensures that
- # the increase is paid by external inputs.
+ # Payjoin transactions may silently increase the value of external outputs, but
+ # non-payjoins cannot. See top comment.
if not self.is_payjoin():
raise ProcessError(
"Increasing original output amounts is not supported."
)
-
- if self.orig_total_in:
- # Skip output confirmation for replacement transactions,
- # but don't allow adding new OP_RETURN outputs.
- if txo.script_type == OutputScriptType.PAYTOOPRETURN and not orig_txo:
+ elif self.orig_total_in:
+ # This is a new external output in a replacement transaction.
+ if txo.script_type == OutputScriptType.PAYTOOPRETURN:
raise ProcessError(
"Adding new OP_RETURN outputs in replacement transactions is not supported."
)
+ # Payjoin transactions may silently introduce new external outputs, but non-payjoins
+ # cannot. See top comment.
+ if not self.is_payjoin():
+ raise ProcessError(
+ "Adding new external outputs in replacement transactions is not supported."
+ )
elif txo.payment_req_index is None:
+ # This is an external output in a standard transaction not referencing a payment request.
source_path = (
tx_info.change_detector.wallet_path.get_path() if tx_info else None
)
- # Ask user to confirm output, unless it is part of a payment
- # request, which gets confirmed separately.
await helpers.confirm_output(
txo,
self.coin,
@@ -247,6 +267,10 @@ class BasicApprover(Approver):
source_path,
)
self.external_output_index += 1
+ else:
+ # This is an external output in a standard transaction referencing a payment request,
+ # which is confirmed separately.
+ pass
async def add_payment_request(
self,
diff --git a/tests/device_tests/bitcoin/test_signtx_replacement.py b/tests/device_tests/bitcoin/test_signtx_replacement.py
index 03753834..7597973b 100644
--- a/tests/device_tests/bitcoin/test_signtx_replacement.py
+++ b/tests/device_tests/bitcoin/test_signtx_replacement.py
@@ -1152,3 +1152,58 @@ def test_p2tr_invalid_signature(session: Session):
with pytest.raises(TrezorFailure, match="Invalid signature"):
btc.sign_tx(session, "Testnet", [inp1, inp2], [out1, out2], prev_txes=prev_txes)
+
+
+def test_attack_add_external_output_in_replacement(session: Session):
+ inp1 = messages.TxInputType(
+ address_n=parse_path("m/49h/1h/0h/0/4"),
+ amount=100_000,
+ script_type=messages.InputScriptType.SPENDP2SHWITNESS,
+ prev_hash=TXHASH_5e7667,
+ prev_index=1,
+ orig_hash=TXHASH_334cd7,
+ orig_index=0,
+ )
+
+ inp2 = messages.TxInputType(
+ address_n=parse_path("m/49h/1h/0h/0/3"),
+ amount=998_060,
+ script_type=messages.InputScriptType.SPENDP2SHWITNESS,
+ prev_hash=TXHASH_efaa41,
+ prev_index=0,
+ orig_hash=TXHASH_334cd7,
+ orig_index=1,
+ )
+
+ # Original recipient, decreased to fund the attacker output.
+ out_bob = messages.TxOutputType(
+ address="2MvUUSiQZDSqyeSdofKX9KrSCio1nANPDTe",
+ amount=990_000,
+ orig_hash=TXHASH_334cd7,
+ orig_index=0,
+ )
+
+ # Attacker's new external output. No orig_hash.
+ out_attacker = messages.TxOutputType(
+ address="tb1q694ccp5qcc0udmfwgp692u2s2hjpq5h407urtu",
+ amount=5_000,
+ script_type=messages.OutputScriptType.PAYTOWITNESS,
+ )
+
+ out_change = messages.TxOutputType(
+ address_n=parse_path("m/49h/1h/0h/1/0"),
+ amount=94_280,
+ script_type=messages.OutputScriptType.PAYTOP2SHWITNESS,
+ )
+
+ with pytest.raises(
+ TrezorFailure,
+ match="Adding new external outputs in replacement transactions is not supported",
+ ):
+ btc.sign_tx(
+ session,
+ "Testnet",
+ [inp1, inp2],
+ [out_bob, out_attacker, out_change],
+ prev_txes=TX_CACHE_TESTNET,
+ )
Why this scored 72/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.