HexBrain

Monero · Report #3698862

`check_reserve_proof` sums RingCT ECDH amounts without checking the output commitment

Validstatus
mediumseverity
37upvotes
5 Aug 2026disclosed

How it was found

Repository: monero-project/monero — src/wallet/wallet2.cpp, src/ringct/rctSigs.cpp. Snapshot: master @ 3ad4a5ee8 (v0.18.1.0-3ad4a5ee8).

Summary

wallet2::check_reserve_proof proves that a reserve-proof entry belongs to the claimed wallet, then decodes the RingCT amount from tx.rct_signatures.ecdhInfo[proof.index_in_tx] and adds it to total. The missing step is the one normal wallet decoding already does: recompute the Pedersen commitment from the decoded (mask, amount) and compare it to tx.rct_signatures.outPk[n].mask.

The vulnerable block is in wallet2.cpp around 12751–12764:

uint64_t amount = tx.vout[proof.index_in_tx].amount;
if (amount == 0)
{
  crypto::secret_key shared_secret;
  crypto::derivation_to_scalar(derivation, proof.index_in_tx, shared_secret);
  rct::ecdhTuple ecdh_info = tx.rct_signatures.ecdhInfo[proof.index_in_tx];
  rct::ecdhDecode(ecdh_info, rct::sk2rct(shared_secret),
      tx.rct_signatures.type == rct::RCTTypeBulletproof2 ||
      tx.rct_signatures.type == rct::RCTTypeCLSAG ||
      tx.rct_signatures.type == rct::RCTTypeBulletproofPlus);
  amount = rct::h2d(ecdh_info.amount);   // no C == mask*G + amount*H check
}
total += amount;

For comparison, the wallet receive path does not trust that decoded value by itself:

const rct::key C = tx.rct_signatures.outPk[n].mask;
rct::key Ctmp;
rct::addKeys2(Ctmp, ecdh_info.mask, ecdh_info.amount, rct::H);
if (rct::equalKeys(C, Ctmp))
  amount = rct::h2d(ecdh_info.amount);
else
  amount = 0;

The lower-level RingCT helper is stricter too. decodeRctSimple() throws warning, amount decoded incorrectly, will be unable to spend when the decoded ECDH tuple does not match the output commitment.

That leaves reserve proof verification on the weaker path: it authenticates ownership of the output, but it does not authenticate the amount it just decoded. A malicious prover wants exactly that gap: a proof verifier that reads attacker-controlled amount metadata and never binds it back to the commitment that consensus actually checked.

Releases Affected

Confirmed on master @ 3ad4a5ee8 (Monero 'Fluorine Fermi' v0.18.1.0-3ad4a5ee8) by source review and a targeted unit test.

The bug is not tied to a recent refactor. Any branch with this check_reserve_proof amount path is worth checking, especially branches after RingCT reserve proofs were added.

Steps to Reproduce

Add the following unit test under tests/unit_tests/ringct.cpp (this is the checked proof I used; it does not touch wallet2.cpp or the reserve-proof verifier):

TEST(ringct, reserveProofStyleDecodeNeedsCommitmentCheck)
{
  static constexpr uint64_t real_amount = 1;
  static constexpr uint64_t fake_amount = 1000000000000;

  const rct::key amount_key = rct::skGen();
  const rct::key commitment_mask = rct::genCommitmentMask(amount_key);
  rct::key output_commitment;
  rct::addKeys2(output_commitment, commitment_mask, rct::d2h(real_amount), rct::H);

  rct::ecdhTuple encoded = {};
  encoded.amount = rct::d2h(fake_amount);
  rct::ecdhEncode(encoded, amount_key, true);

  rct::ecdhTuple reserve_proof_style_decode = encoded;
  rct::ecdhDecode(reserve_proof_style_decode, amount_key, true);
  ASSERT_EQ(fake_amount, rct::h2d(reserve_proof_style_decode.amount));

  rct::key recomputed_commitment;
  rct::addKeys2(recomputed_commitment, reserve_proof_style_decode.mask,
      reserve_proof_style_decode.amount, rct::H);
  ASSERT_NE(output_commitment, recomputed_commitment);

  rct::rctSig rv;
  rv.type = rct::RCTTypeCLSAG;
  rv.ecdhInfo.push_back(encoded);
  rv.outPk.push_back({});
  rv.outPk[0].mask = output_commitment;

  rct::key decoded_mask;
  EXPECT_THROW(
    (void)rct::decodeRctSimple(rv, amount_key, 0, decoded_mask, hw::get_device("default")),
    std::runtime_error
  );
}

Build and run only that test:

cmake -S /home/beni/Monero/monero -B /tmp/monero-official-build \
  -D CMAKE_BUILD_TYPE=Release -D BUILD_TESTS=ON -D MANUAL_SUBMODULES=1
cmake --build /tmp/monero-official-build -j"$(nproc)" --target unit_tests

/tmp/monero-official-build/tests/unit_tests/unit_tests \
  --gtest_filter=ringct.reserveProofStyleDecodeNeedsCommitmentCheck

Observed output:

Note: Google Test filter = ringct.reserveProofStyleDecodeNeedsCommitmentCheck
[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from ringct
[ RUN      ] ringct.reserveProofStyleDecodeNeedsCommitmentCheck
2026-04-27 16:12:56.768 E warning, amount decoded incorrectly, will be unable to spend
[       OK ] ringct.reserveProofStyleDecodeNeedsCommitmentCheck (3 ms)
[----------] 1 test from ringct (3 ms total)

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

{F5810252} What the test proves:

  1. The ECDH metadata can decode to fake_amount = 1000000000000.
  2. That decoded amount does not match the real output commitment for real_amount = 1.
  3. The normal RingCT decode helper catches the mismatch.
  4. A verifier that only does ecdhDecode() + h2d() and then sums the result is missing the decisive check.

That last line is exactly what check_reserve_proof does today.

Possible Solution

Use the same commitment check in check_reserve_proof that the wallet receive path and decodeRctSimple() already use.

Minimal patch shape inside the RingCT amount branch:

crypto::secret_key shared_secret;
crypto::derivation_to_scalar(derivation, proof.index_in_tx, shared_secret);

rct::ecdhTuple ecdh_info = tx.rct_signatures.ecdhInfo[proof.index_in_tx];
const bool v2 =
    tx.rct_signatures.type == rct::RCTTypeBulletproof2 ||
    tx.rct_signatures.type == rct::RCTTypeCLSAG ||
    tx.rct_signatures.type == rct::RCTTypeBulletproofPlus;
rct::ecdhDecode(ecdh_info, rct::sk2rct(shared_secret), v2);

THROW_WALLET_EXCEPTION_IF(sc_check(ecdh_info.mask.bytes) != 0,
    error::wallet_internal_error, "Bad ECDH input mask");
THROW_WALLET_EXCEPTION_IF(sc_check(ecdh_info.amount.bytes) != 0,
    error::wallet_internal_error, "Bad ECDH input amount");

rct::key Ctmp;
rct::addKeys2(Ctmp, ecdh_info.mask, ecdh_info.amount, rct::H);
if (!rct::equalKeys(tx.rct_signatures.outPk[proof.index_in_tx].mask, Ctmp))
  return false;

amount = rct::h2d(ecdh_info.amount);

Add a regression test close to the unit test above, and ideally a wallet-level test once there is a small helper for building a reserve proof around a deliberately malformed output.

This should be fixed alongside the duplicate-entry issue in check_reserve_proof: reject duplicate key images and duplicate (txid, index_in_tx) entries before fetching transactions or summing amounts.

Impact

If an attacker can get a confirmed output plus reserve proof into this shape, the official verifier can report reserves that are larger than the commitment-backed amount on-chain. The business impact is not wallet theft; it is false solvency. An exchange, bridge custodian, lender, OTC desk, or auditor relying on check_reserve_proof could accept collateral or reserves that do not exist.

The current evidence is the unit test above, not an end-to-end network exploit. I would not present it as "one-command theft" or as a finished exploit. The right framing is narrower: the reserve-proof verifier uses a weaker amount-decoding path than the wallet itself, and the test shows why the commitment check exists elsewhere in the codebase.

Note

This write-up and the gtest were prepared with AI/editor assistance for code navigation, wording, and test scaffolding. The line references, build commands, and test output above were checked against the local tree at 3ad4a5ee8.

Bounty

If this qualifies for a bounty, XMR can be sent to: 47TUANy82gtBFMvsFwyA3hE98PzgKSmxNia3n9fpvSadDWs234M7oWfevXqYxKmy3d9hSp1TH82gX4JWvNEUupse2s92maV

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