ringct: fuzz and harden Bulletproof verification
What changed, and why it matters
This commit adds a safety cap on the number of proof elements (V) that Monero's Bulletproof and Bulletproof+ verifiers will accept. Before the change, a malformed proof could claim an unusually large number of outputs, potentially causing excessive computation, memory use, or verifier misbehavior. The patch also adds fuzz tests and unit tests to exercise this limit. It is a hardening fix rather than a confirmed exploit for theft or forgery.
Treat as a low-to-moderate hardening improvement. Review whether maxM is enforced consistently across all deserialization and RPC paths that accept Bulletproof/Bulletproof+ structures, and backport the bound to maintained release branches. Run the new fuzz and unit tests in CI.
Security signals we found
Input-size bound added to cryptographic verifier (V <= maxM)
Fuzz target added/updated for Bulletproof verification
Unit test added for 'too many outputs' rejection
No explicit CVE, advisory, or vendor security statement present in commit
Evidence from the diff
The patch enforces proof.V.size() <= maxM in both bulletproof_VERIFY (src/ringct/bulletproofs.cc) and bulletproof_plus_VERIFY (src/ringct/bulletproofs_plus.cc). maxM is an existing protocol constant (the maximum number of outputs per proof). The change prevents oversized V vectors from reaching the rest of the verification logic. Supporting test changes add a focused fuzz target and unit tests that construct a 16-output proof, append an extra V/L/R triple, and assert verification now fails.
Changed components
src/ringct/bulletproofs.ccsrc/ringct/bulletproofs_plus.cctests/fuzz/bulletproof.cpptests/unit_tests/bulletproofs.cpptests/unit_tests/bulletproofs_plus.cppInspect captured patch +61 / −12
diff --git a/src/ringct/bulletproofs.cc b/src/ringct/bulletproofs.cc
index cf1ddc8..2427402 100644
--- a/src/ringct/bulletproofs.cc
+++ b/src/ringct/bulletproofs.cc
@@ -837,6 +837,7 @@ bool bulletproof_VERIFY(const std::vector<const Bulletproof*> &proofs)
CHECK_AND_ASSERT_MES(is_reduced(proof.t), false, "Input scalar not in range");
CHECK_AND_ASSERT_MES(proof.V.size() >= 1, false, "V does not have at least one element");
+ CHECK_AND_ASSERT_MES(proof.V.size() <= maxM, false, "V has too many elements");
CHECK_AND_ASSERT_MES(proof.L.size() == proof.R.size(), false, "Mismatched L and R sizes");
CHECK_AND_ASSERT_MES(proof.L.size() > 0, false, "Empty proof");
diff --git a/src/ringct/bulletproofs_plus.cc b/src/ringct/bulletproofs_plus.cc
index ea2cd34..cf9990b 100644
--- a/src/ringct/bulletproofs_plus.cc
+++ b/src/ringct/bulletproofs_plus.cc
@@ -827,6 +827,7 @@ try_again:
CHECK_AND_ASSERT_MES(is_reduced(proof.d1), false, "Input scalar not in range");
CHECK_AND_ASSERT_MES(proof.V.size() >= 1, false, "V does not have at least one element");
+ CHECK_AND_ASSERT_MES(proof.V.size() <= maxM, false, "V has too many elements");
CHECK_AND_ASSERT_MES(proof.L.size() == proof.R.size(), false, "Mismatched L and R sizes");
CHECK_AND_ASSERT_MES(proof.L.size() > 0, false, "Empty proof");
diff --git a/tests/data/fuzz/bulletproof/BP0 b/tests/data/fuzz/bulletproof/BP0
index 17590b7..5f7d0c7 100644
Binary files a/tests/data/fuzz/bulletproof/BP0 and b/tests/data/fuzz/bulletproof/BP0 differ
diff --git a/tests/data/fuzz/bulletproof/BP1 b/tests/data/fuzz/bulletproof/BP1
new file mode 100644
index 0000000..9a8814a
Binary files /dev/null and b/tests/data/fuzz/bulletproof/BP1 differ
diff --git a/tests/data/fuzz/bulletproof/BP2 b/tests/data/fuzz/bulletproof/BP2
new file mode 100644
index 0000000..f31ddf6
Binary files /dev/null and b/tests/data/fuzz/bulletproof/BP2 differ
diff --git a/tests/data/fuzz/bulletproof/BP3 b/tests/data/fuzz/bulletproof/BP3
new file mode 100644
index 0000000..4a2dd07
Binary files /dev/null and b/tests/data/fuzz/bulletproof/BP3 differ
diff --git a/tests/data/fuzz/bulletproof/BP4 b/tests/data/fuzz/bulletproof/BP4
new file mode 100644
index 0000000..13425af
Binary files /dev/null and b/tests/data/fuzz/bulletproof/BP4 differ
diff --git a/tests/fuzz/CMakeLists.txt b/tests/fuzz/CMakeLists.txt
index 6db431e..b130f5a 100644
--- a/tests/fuzz/CMakeLists.txt
+++ b/tests/fuzz/CMakeLists.txt
@@ -256,11 +256,8 @@ set_property(TARGET levin_fuzz_tests
monero_add_minimal_executable(bulletproof_fuzz_tests bulletproof.cpp fuzzer.cpp)
target_link_libraries(bulletproof_fuzz_tests
PRIVATE
- common
- epee
- ${Boost_THREAD_LIBRARY}
- ${Boost_REGEX_LIBRARY}
- ${Boost_PROGRAM_OPTIONS_LIBRARY}
+ ringct
+ serialization
${CMAKE_THREAD_LIBS_INIT}
${EXTRA_LIBRARIES}
$ENV{LIB_FUZZING_ENGINE})
diff --git a/tests/fuzz/bulletproof.cpp b/tests/fuzz/bulletproof.cpp
index d2eb875..25ff21f 100644
--- a/tests/fuzz/bulletproof.cpp
+++ b/tests/fuzz/bulletproof.cpp
@@ -27,17 +27,39 @@
// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#include "include_base_utils.h"
-#include "file_io_utils.h"
-#include "cryptonote_basic/blobdatatype.h"
-#include "cryptonote_basic/cryptonote_basic.h"
-#include "cryptonote_basic/cryptonote_format_utils.h"
#include "fuzzer.h"
+#include "ringct/bulletproofs.h"
+#include "serialization/binary_utils.h"
+
+#include <string>
+#include <utility>
+
+namespace
+{
+ struct fuzz_input
+ {
+ rct::keyV commitments;
+ rct::Bulletproof proof;
+
+ BEGIN_SERIALIZE_OBJECT()
+ FIELD(commitments)
+ FIELD(proof)
+ END_SERIALIZE()
+ };
+}
BEGIN_INIT_SIMPLE_FUZZER()
END_INIT_SIMPLE_FUZZER()
BEGIN_SIMPLE_FUZZER()
- binary_archive<false> ba{{buf, len}};
- rct::Bulletproof proof{};
- ::serialization::serialize(ba, proof);
+ if (len > 4096)
+ return 0;
+
+ fuzz_input input;
+ const std::string blob{reinterpret_cast<const char*>(buf), len};
+ if (!serialization::parse_binary(blob, input))
+ return 0;
+
+ input.proof.V = std::move(input.commitments);
+ rct::bulletproof_VERIFY(input.proof);
END_SIMPLE_FUZZER()
diff --git a/tests/unit_tests/bulletproofs.cpp b/tests/unit_tests/bulletproofs.cpp
index 5d8d4d3..197ae8b 100644
--- a/tests/unit_tests/bulletproofs.cpp
+++ b/tests/unit_tests/bulletproofs.cpp
@@ -77,6 +77,20 @@ TEST(bulletproofs, valid_multi_random)
}
}
+TEST(bulletproofs, invalid_too_many_outputs)
+{
+ std::vector<uint64_t> amounts(16, 0);
+ rct::keyV gamma(16);
+ for (rct::key &mask: gamma)
+ mask = rct::skGen();
+ rct::Bulletproof proof = bulletproof_PROVE(amounts, gamma);
+ ASSERT_TRUE(rct::bulletproof_VERIFY(proof));
+ proof.V.push_back(proof.V.back());
+ proof.L.push_back(proof.L.back());
+ proof.R.push_back(proof.R.back());
+ ASSERT_FALSE(rct::bulletproof_VERIFY(proof));
+}
+
TEST(bulletproofs, multi_splitting)
{
rct::ctkeyV sc, pc;
diff --git a/tests/unit_tests/bulletproofs_plus.cpp b/tests/unit_tests/bulletproofs_plus.cpp
index c1b5e13..b998c51 100644
--- a/tests/unit_tests/bulletproofs_plus.cpp
+++ b/tests/unit_tests/bulletproofs_plus.cpp
@@ -77,6 +77,20 @@ TEST(bulletproofs_plus, valid_multi_random)
}
}
+TEST(bulletproofs_plus, invalid_too_many_outputs)
+{
+ std::vector<uint64_t> amounts(16, 0);
+ rct::keyV gamma(16);
+ for (rct::key &mask: gamma)
+ mask = rct::skGen();
+ rct::BulletproofPlus proof = bulletproof_plus_PROVE(amounts, gamma);
+ ASSERT_TRUE(rct::bulletproof_plus_VERIFY(proof));
+ proof.V.push_back(proof.V.back());
+ proof.L.push_back(proof.L.back());
+ proof.R.push_back(proof.R.back());
+ ASSERT_FALSE(rct::bulletproof_plus_VERIFY(proof));
+}
+
TEST(bulletproofs_plus, valid_aggregated)
{
static const size_t N_PROOFS = 8;
Why this scored 59/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.