wallet2: reject malformed multisig_info on import
What changed, and why it matters
This Monero wallet update adds safety checks when a multisig wallet imports data shared by other signers. Before, the code trusted the size and claimed signer of imported multisig data too much; now it rejects blobs with the wrong number of partial key images, wrong number of signing nonces, or a signer that does not match the file header. The change is defensive and prevents malformed or mismatched data from being processed further, but the commit message does not call it a security fix and no exploit is demonstrated.
Treat as a hardening/defensive patch. Review whether any prior release accepted malformed blobs and assess whether a security advisory is warranted based on whether the missing checks could lead to incorrect multisig state or signature behavior. No immediate emergency response is indicated by the diff alone.
Security signals we found
Input validation added to multisig import path
Mismatch between header signer and body signer now rejected
Unexpected vector lengths in imported multisig data now rejected
Subgroup checks already existed; this patch adds structural checks before them
New unit tests specifically target malformed import blobs
Evidence from the diff
wallet2::import_multisig now validates each deserialized multisig_info entry before subgroup/point checks. It computes expected counts: expected_n_partial_key_images from the account’s multisig key count, and expected_n_LR from combinations_count(N-M, N-1) * kAlphaComponents. It then enforces that e.m_signer equals the header signer, that e.m_partial_key_images.size() equals the expected count, and that e.m_LR.size() equals the expected count. Unit tests exercise acceptance of a well-formed 2-of-2 blob and rejection of wrong LR count, wrong partial-key-image count, and signer mismatch. The patch is purely additive validation; it does not alter downstream logic.
Changed components
src/wallet/wallet2.cpptests/unit_tests/multisig.cppwallet2::import_multisigmultisig_info deserialization/validationInspect captured patch +120 / −0
diff --git a/src/wallet/wallet2.cpp b/src/wallet/wallet2.cpp
index 86eba93..d8e3a5f 100644
--- a/src/wallet/wallet2.cpp
+++ b/src/wallet/wallet2.cpp
@@ -14655,6 +14655,11 @@ size_t wallet2::import_multisig(std::vector<cryptonote::blobdata> blobs, bool re
// parse and validate locally so failures preserve any pending rescan state
std::vector<std::vector<tools::wallet2::multisig_info>> info;
std::unordered_set<crypto::public_key> seen;
+
+ const size_t expected_n_partial_key_images = get_account().get_multisig_keys().size();
+ const size_t expected_n_lr = tools::combinations_count(m_multisig_signers.size() - m_multisig_threshold, m_multisig_signers.size() - 1)
+ * multisig::signing::kAlphaComponents;
+
for (cryptonote::blobdata &data: blobs)
{
const size_t magiclen = strlen(MULTISIG_EXPORT_FILE_MAGIC);
@@ -14701,6 +14706,13 @@ size_t wallet2::import_multisig(std::vector<cryptonote::blobdata> blobs, bool re
for (const auto &e: i)
{
+ CHECK_AND_ASSERT_THROW_MES(e.m_signer == signer, "Multisig info body signer does not match header signer");
+
+ CHECK_AND_ASSERT_THROW_MES(e.m_partial_key_images.size() == expected_n_partial_key_images,
+ "Multisig info has an unexpected number of partial key images");
+ CHECK_AND_ASSERT_THROW_MES(e.m_LR.size() == expected_n_lr,
+ "Multisig info has an unexpected number of signing nonces");
+
for (const auto &lr: e.m_LR)
{
CHECK_AND_ASSERT_THROW_MES(rct::isInMainSubgroup(lr.m_L), "Multisig value is not in the main subgroup");
diff --git a/tests/unit_tests/multisig.cpp b/tests/unit_tests/multisig.cpp
index 78d4bed..4e2cd7b 100644
--- a/tests/unit_tests/multisig.cpp
+++ b/tests/unit_tests/multisig.cpp
@@ -27,14 +27,19 @@
// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#include "crypto/crypto.h"
+#include "multisig/multisig.h"
#include "multisig/multisig_account.h"
#include "multisig/multisig_kex_msg.h"
#include "ringct/rctOps.h"
+#include "serialization/binary_utils.h"
#include "wallet/wallet2.h"
+#include "wallet/wallet2_basic/wallet2_boost_serialization.h"
+#include "wallet/wallet2_basic/wallet2_serialization.h"
#include "gtest/gtest.h"
#include <cstdint>
+#include <sstream>
static const struct
{
@@ -497,3 +502,106 @@ TEST(multisig, multisig_kex_msg)
EXPECT_EQ(msg_rnd2.get_msg_privkey(), crypto::null_skey);
EXPECT_EQ(msg_rnd2.get_msg_privkey(), msg_rnd2_reverse.get_msg_privkey());
}
+
+TEST(multisig, import_multisig_validation)
+{
+ using namespace multisig;
+
+ // a 2-of-2 wallet pair is enough: no funded outputs are needed for this test, since the checks under test
+ // run on the deserialized contents of an import blob before anything touches m_transfers
+ const std::uint32_t M = 2, N = 2;
+ std::vector<tools::wallet2> wallets(N);
+
+ std::vector<std::string> initial_infos(wallets.size());
+ for (size_t i = 0; i < wallets.size(); ++i)
+ {
+ make_wallet(i, wallets[i]);
+ wallets[i].decrypt_keys("");
+ initial_infos[i] = wallets[i].get_multisig_first_kex_msg();
+ wallets[i].encrypt_keys("");
+ }
+
+ std::vector<std::string> intermediate_infos(wallets.size());
+ for (size_t i = 0; i < wallets.size(); ++i)
+ intermediate_infos[i] = wallets[i].make_multisig("", initial_infos, M);
+
+ multisig_account_status ms_status{wallets[0].get_multisig_status()};
+ while (!ms_status.is_ready)
+ {
+ intermediate_infos = exchange_round(wallets, intermediate_infos);
+ ms_status = wallets[0].get_multisig_status();
+ }
+
+ wallets[0].decrypt_keys("");
+ wallets[1].decrypt_keys("");
+
+ // 2-of-2 => each signer holds exactly 1 multisig private key, and each output needs exactly
+ // combinations_count(N-M, N-1) * kAlphaComponents = combinations_count(0, 1) * 2 = 2 LR nonce pairs
+ ASSERT_EQ(1u, wallets[1].get_account().get_multisig_keys().size());
+ const crypto::public_key other_signer = wallets[1].get_multisig_signer_public_key();
+
+ // a fake output pubkey: any valid curve point works here, since it's only used to derive LR pairs/key
+ // images with the right shape (main-subgroup curve points); it doesn't need to correspond to a real output
+ crypto::public_key fake_out_key;
+ ASSERT_TRUE(crypto::secret_key_to_public_key(rct::rct2sk(rct::skGen()), fake_out_key));
+
+ crypto::key_image valid_pki;
+ ASSERT_TRUE(generate_multisig_key_image(wallets[1].get_account().get_keys(), 0, fake_out_key, valid_pki));
+
+ tools::wallet2::multisig_info::LR valid_lr;
+ {
+ const crypto::secret_key k = rct::rct2sk(rct::skGen());
+ crypto::public_key L, R;
+ generate_multisig_LR(fake_out_key, k, L, R);
+ valid_lr.m_L = rct::pk2rct(L);
+ valid_lr.m_R = rct::pk2rct(R);
+ }
+
+ tools::wallet2::multisig_info valid_entry;
+ valid_entry.m_signer = other_signer;
+ valid_entry.m_LR = {valid_lr, valid_lr};
+ valid_entry.m_partial_key_images = {valid_pki};
+
+ // builds a raw multisig-info import blob (same wire format as wallet2::export_multisig()) for a single
+ // fake output, so wallet2::import_multisig()'s per-output validation can be exercised directly
+ const auto build_blob =
+ [&](const crypto::public_key &header_signer, const tools::wallet2::multisig_info &entry) -> cryptonote::blobdata
+ {
+ std::vector<tools::wallet2::multisig_info> info{entry};
+ std::stringstream oss;
+ binary_archive<true> ar(oss);
+ EXPECT_TRUE(::serialization::serialize(ar, info));
+
+ const cryptonote::account_public_address &addr = wallets[0].get_account().get_keys().m_account_address;
+ std::string header;
+ header.append((const char *)&addr.m_spend_public_key, sizeof(crypto::public_key));
+ header.append((const char *)&addr.m_view_public_key, sizeof(crypto::public_key));
+ header.append((const char *)&header_signer, sizeof(crypto::public_key));
+
+ return std::string("Monero multisig export\001") + wallets[0].encrypt_with_view_secret_key(header + oss.str());
+ };
+
+ // well-formed info is accepted
+ EXPECT_NO_THROW(wallets[0].import_multisig({build_blob(other_signer, valid_entry)}, false));
+
+ // wrong number of LR nonce pairs is rejected
+ {
+ tools::wallet2::multisig_info bad_entry = valid_entry;
+ bad_entry.m_LR = {valid_lr}; // should be 2
+ EXPECT_ANY_THROW(wallets[0].import_multisig({build_blob(other_signer, bad_entry)}, false));
+ }
+
+ // wrong number of partial key images is rejected
+ {
+ tools::wallet2::multisig_info bad_entry = valid_entry;
+ bad_entry.m_partial_key_images.clear(); // should be 1
+ EXPECT_ANY_THROW(wallets[0].import_multisig({build_blob(other_signer, bad_entry)}, false));
+ }
+
+ // a body-declared signer that doesn't match the header signer it was filed under is rejected
+ {
+ tools::wallet2::multisig_info bad_entry = valid_entry;
+ bad_entry.m_signer = wallets[0].get_multisig_signer_public_key();
+ EXPECT_ANY_THROW(wallets[0].import_multisig({build_blob(other_signer, bad_entry)}, false));
+ }
+}
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.