fix(monero): reject sweep change aliasing money-carrying outputs
What changed, and why it matters
This update fixes a flaw in how Trezor handled certain Monero 'sweep' transactions. In a sweep, the device normally skips checking whether the change address truly belongs to the user, because the change output is supposed to carry zero coins and go to a random address. A malicious computer or wallet software could have reused that unvalidated change address for an output that actually holds money. The device would then treat that money as change and lock it with a key the user might not control, effectively freezing or misdirecting funds. The fix rejects such transactions unless the address is the user's own primary address, where the math happens to be safe either way.
Users should upgrade Trezor firmware to a version containing this commit. Developers and wallet integrators should review any custom Monero signing flows that rely on the sweep-shape exemption and ensure they enforce the same primary-address exception.
Security signals we found
Change-address validation bypass in sweep-shape transactions
Potential aliasing of money-carrying output with unvalidated change address
Funds could be locked under an uncontrolled one-time key
Primary-address exception preserved due to a*R == r*A key equivalence
New regression test added for sweep-to-primary-address acceptance
Evidence from the diff
In Monero transaction signing, step_01_init_transaction.py’s _check_change() had a sweep-shape exemption: when change_index is None, the declared change amount is 0, and there are exactly two outputs, the device did not validate the change address as owned. The code already checked that no money-carrying output reused the change address, but it did not exempt the user’s primary address. The patch computes the primary change address and allows the exemption only if change_addr equals that primary address. The cryptographic reasoning is that for the primary address, aR == rA, so step 6 derives the same one-time key regardless of whether the output is classified as change or payment, preserving spendability. A new test confirms sweep-to-primary-address is accepted.
Changed components
core/src/apps/monero/signing/step_01_init_transaction.pycore/tests/test_apps.monero.change.pyTrezor Monero transaction signing flowInspect captured patch +20 / −5
### core/src/apps/monero/signing/step_01_init_transaction.py
@@ -275,12 +275,20 @@ def _check_change(
# that spends exactly 0 coins to a random address.
# See https://github.com/monero-project/monero/pull/1415
if change_index is None and state.output_change.amount == 0 and len(outputs) == 2:
- # The change address is not validated as ours on this path -- for a sweep it is
- # the random address of the fake output. It must therefore never be the address
- # of an output that actually carries money, otherwise that output would be keyed
- # as change (a*R) in step 6 and nobody would be able to spend it.
+ # The sweep shape is exempt from change ownership validation because the declared
+ # change is normally the fake 0-amount output sent to a throwaway address.
+ # But the host controls this shape, so if a money-carrying output, including a
+ # recipient or one of our own subaddresses, reuses that unvalidated change
+ # address we must reject it. Honest sweeps are unaffected because their fake
+ # output carries amount 0. Our own primary address is the one safe exception,
+ # where step 6 derives the same one-time key either way (`a*R == r*A`).
+ my_addr = _get_primary_change_address(state)
for out in outputs:
- if out.amount and addr_eq(out.addr, change_addr):
+ if (
+ out.amount
+ and addr_eq(out.addr, change_addr)
+ and not addr_eq(change_addr, my_addr)
+ ):
raise signing.ChangeAddressError("Change address spends to a recipient")
state.mem_trace("Sweep tsx" if __debug__ else None)
return
### core/tests/test_apps.monero.change.py
@@ -106,6 +106,13 @@ def test_sweep_shape_with_our_own_change_is_accepted(self):
outputs = [self._dst(1000, self.recipient), self._dst(0, self.ours)]
self._check(change, outputs)
+ def test_sweep_to_our_primary_address_is_accepted(self):
+ # If the fake sweep output aliases our own primary address, step 6 still derives
+ # the same one-time key for the paying output (`a*R == r*A`).
+ change = self._dst(0, self.ours)
+ outputs = [self._dst(1000, self.ours), self._dst(0, self.ours)]
+ self._check(change, outputs)
+
# --- non-sweep shapes ----------------------------------------------------
def test_no_change(self):Why this scored 71/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.