Skip to content

Commit 6e2943a

Browse files
authored
fix: Transaction list validation (#1275)
Signed-off-by: gsstoykov <georgi.stoykov@limechain.tech>
1 parent 48df0e7 commit 6e2943a

File tree

3 files changed

+305
-0
lines changed

3 files changed

+305
-0
lines changed

src/sdk/main/src/ChunkedTransaction.cc

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,20 @@ ChunkedTransaction<SdkRequestType>::ChunkedTransaction(
415415
return;
416416
}
417417

418+
// Security: Determine the expected transaction type from the first entry.
419+
// All chunks must have the same transaction type to prevent transaction smuggling attacks.
420+
proto::TransactionBody::DataCase expectedDataCase = proto::TransactionBody::DATA_NOT_SET;
421+
if (!transactions.empty() && !transactions.cbegin()->second.empty())
422+
{
423+
const proto::Transaction& firstTx = transactions.cbegin()->second.cbegin()->second;
424+
proto::SignedTransaction signedTx;
425+
signedTx.ParseFromArray(firstTx.signedtransactionbytes().data(),
426+
static_cast<int>(firstTx.signedtransactionbytes().size()));
427+
proto::TransactionBody txBody;
428+
txBody.ParseFromArray(signedTx.bodybytes().data(), static_cast<int>(signedTx.bodybytes().size()));
429+
expectedDataCase = txBody.data_case();
430+
}
431+
418432
// Go through each additional TransactionId and store its information.
419433
auto iter = transactions.cbegin();
420434
std::advance(iter, 1);
@@ -432,6 +446,23 @@ ChunkedTransaction<SdkRequestType>::ChunkedTransaction(
432446
// ID, then no more account IDs will be in the map.
433447
if (!(accountId == Transaction<SdkRequestType>::DUMMY_ACCOUNT_ID))
434448
{
449+
// Security: Validate that this chunk has the same transaction type as the first chunk.
450+
// This is defense-in-depth against transaction smuggling attacks where an attacker
451+
// might try to inject a different transaction type (e.g., CryptoTransfer) as a "chunk"
452+
// of what appears to be a FileAppend or TopicMessageSubmit transaction.
453+
proto::SignedTransaction signedTx;
454+
signedTx.ParseFromArray(transaction.signedtransactionbytes().data(),
455+
static_cast<int>(transaction.signedtransactionbytes().size()));
456+
proto::TransactionBody txBody;
457+
txBody.ParseFromArray(signedTx.bodybytes().data(), static_cast<int>(signedTx.bodybytes().size()));
458+
459+
if (txBody.data_case() != expectedDataCase)
460+
{
461+
throw std::invalid_argument(
462+
"ChunkedTransaction contains chunks with inconsistent transaction types. "
463+
"All chunks must have the same transaction type to prevent transaction smuggling attacks.");
464+
}
465+
435466
Transaction<SdkRequestType>::addTransaction(transaction);
436467
}
437468
}

src/sdk/main/src/Transaction.cc

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ WrappedTransaction Transaction<SdkRequestType>::fromBytes(const std::vector<std:
199199
proto::Transaction tx;
200200
proto::SignedTransaction signedTx;
201201
proto::TransactionBody txBody;
202+
proto::TransactionBody::DataCase expectedDataCase = proto::TransactionBody::DATA_NOT_SET;
202203

203204
bool batchified = false;
204205

@@ -222,13 +223,28 @@ WrappedTransaction Transaction<SdkRequestType>::fromBytes(const std::vector<std:
222223
{
223224
std::string currentGroupRefBodyBytes;
224225
std::string currentGroupTxIdBytes;
226+
225227
for (int i = 0; i < txList.transaction_list_size(); ++i)
226228
{
227229
tx = txList.transaction_list(i);
228230
signedTx.ParseFromArray(tx.signedtransactionbytes().data(),
229231
static_cast<int>(tx.signedtransactionbytes().size()));
230232
txBody.ParseFromArray(signedTx.bodybytes().data(), static_cast<int>(signedTx.bodybytes().size()));
231233

234+
// Security: Validate that all entries have the same transaction type.
235+
// This is critical to prevent transaction smuggling where an attacker injects
236+
// a hidden malicious transaction with a different type that gets signed but not displayed.
237+
if (i == 0)
238+
{
239+
expectedDataCase = txBody.data_case();
240+
}
241+
else if (txBody.data_case() != expectedDataCase)
242+
{
243+
throw std::invalid_argument(
244+
"Transaction list contains entries with inconsistent transaction types. "
245+
"All entries must have the same transaction type to prevent transaction smuggling attacks.");
246+
}
247+
232248
std::string thisTxIdBytes = txBody.transactionid().SerializeAsString();
233249

234250
// Create a sanitized body (removing NodeAccountID) for comparison
@@ -293,6 +309,23 @@ WrappedTransaction Transaction<SdkRequestType>::fromBytes(const std::vector<std:
293309
}
294310
}
295311

312+
// Security: For non-chunked transaction types, reject if there are multiple transactionIds.
313+
// Only FileAppend and TopicMessageSubmit (chunked transactions) legitimately have multiple transactionIds.
314+
// This prevents an attacker from smuggling hidden transactions with different transactionIds
315+
// that would get signed but not displayed to the user.
316+
const auto transactionType =
317+
(expectedDataCase != proto::TransactionBody::DATA_NOT_SET) ? expectedDataCase : txBody.data_case();
318+
const bool isChunkedTransactionType = (transactionType == proto::TransactionBody::kFileAppend ||
319+
transactionType == proto::TransactionBody::kConsensusSubmitMessage);
320+
321+
if (!isChunkedTransactionType && transactions.size() > 1)
322+
{
323+
throw std::invalid_argument(
324+
"Non-chunked transaction types cannot have multiple transaction IDs. "
325+
"This may indicate an attempt to smuggle hidden transactions. "
326+
"Only FileAppend and TopicMessageSubmit support multiple transaction IDs for chunking.");
327+
}
328+
296329
switch (txBody.data_case())
297330
{
298331
case proto::TransactionBody::kCryptoApproveAllowance:

src/sdk/tests/unit/TransactionUnitTests.cc

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3174,3 +3174,244 @@ TEST_F(TransactionUnitTests, RemoveSignatureFromMultiNodeTransaction)
31743174
// Verify that all internal signatory tracking and protobuf signature maps are completely clear
31753175
EXPECT_EQ(finalRemoval.size(), 0);
31763176
}
3177+
3178+
//-----
3179+
// Security Test: Prevent transaction smuggling attacks (CVE fix)
3180+
// This test verifies that fromBytes() rejects TransactionLists containing entries
3181+
// with different transaction types, which could be used to smuggle a malicious
3182+
// transaction that gets signed but not displayed to the user.
3183+
TEST_F(TransactionUnitTests, FromBytesRejectsTransactionListWithMixedTransactionTypes)
3184+
{
3185+
// Given
3186+
// Entry 1: A benign TransferTransaction (CryptoTransfer)
3187+
proto::TransactionBody benignBody;
3188+
benignBody.set_memo("Benign transfer");
3189+
benignBody.set_allocated_cryptotransfer(new proto::CryptoTransferTransactionBody);
3190+
3191+
proto::SignedTransaction signedTx1;
3192+
signedTx1.set_bodybytes(benignBody.SerializeAsString());
3193+
proto::Transaction tx1;
3194+
tx1.set_signedtransactionbytes(signedTx1.SerializeAsString());
3195+
3196+
// Entry 2: A different transaction type (AccountCreate) - this simulates
3197+
// an attacker trying to smuggle a different transaction type
3198+
proto::TransactionBody maliciousBody;
3199+
maliciousBody.set_memo("Hidden transaction");
3200+
maliciousBody.set_allocated_cryptocreateaccount(new proto::CryptoCreateTransactionBody);
3201+
3202+
proto::SignedTransaction signedTx2;
3203+
signedTx2.set_bodybytes(maliciousBody.SerializeAsString());
3204+
proto::Transaction tx2;
3205+
tx2.set_signedtransactionbytes(signedTx2.SerializeAsString());
3206+
3207+
// Create a TransactionList with mixed types
3208+
proto::TransactionList txList;
3209+
*txList.add_transaction_list() = tx1;
3210+
*txList.add_transaction_list() = tx2;
3211+
3212+
// When / Then
3213+
// This should throw because entries have different transaction types
3214+
EXPECT_THROW(
3215+
Transaction<TransferTransaction>::fromBytes(internal::Utilities::stringToByteVector(txList.SerializeAsString())),
3216+
std::invalid_argument);
3217+
}
3218+
3219+
//-----
3220+
// Security Test: Prevent transaction smuggling via multiple transactionIds
3221+
// For non-chunked transaction types, multiple transactionIds are not allowed
3222+
// as they could be used to hide malicious transactions.
3223+
TEST_F(TransactionUnitTests, FromBytesRejectsNonChunkedTransactionWithMultipleTransactionIds)
3224+
{
3225+
// Given
3226+
// Entry 1: TransferTransaction with TransactionId A
3227+
proto::TransactionBody txBody1;
3228+
txBody1.set_memo("Transaction 1");
3229+
txBody1.set_allocated_cryptotransfer(new proto::CryptoTransferTransactionBody);
3230+
3231+
proto::TransactionID txId1;
3232+
txId1.mutable_accountid()->set_accountnum(201);
3233+
txId1.mutable_transactionvalidstart()->set_seconds(1700000010);
3234+
txId1.mutable_transactionvalidstart()->set_nanos(1);
3235+
txBody1.set_allocated_transactionid(new proto::TransactionID(txId1));
3236+
3237+
proto::SignedTransaction signedTx1;
3238+
signedTx1.set_bodybytes(txBody1.SerializeAsString());
3239+
proto::Transaction tx1;
3240+
tx1.set_signedtransactionbytes(signedTx1.SerializeAsString());
3241+
3242+
// Entry 2: Same type but different TransactionId (different timestamp)
3243+
// This simulates an attacker hiding a second transaction
3244+
proto::TransactionBody txBody2;
3245+
txBody2.set_memo("Transaction 1"); // Same memo to look identical
3246+
txBody2.set_allocated_cryptotransfer(new proto::CryptoTransferTransactionBody);
3247+
3248+
proto::TransactionID txId2;
3249+
txId2.mutable_accountid()->set_accountnum(201);
3250+
txId2.mutable_transactionvalidstart()->set_seconds(1700000011); // Different timestamp!
3251+
txId2.mutable_transactionvalidstart()->set_nanos(1);
3252+
txBody2.set_allocated_transactionid(new proto::TransactionID(txId2));
3253+
3254+
proto::SignedTransaction signedTx2;
3255+
signedTx2.set_bodybytes(txBody2.SerializeAsString());
3256+
proto::Transaction tx2;
3257+
tx2.set_signedtransactionbytes(signedTx2.SerializeAsString());
3258+
3259+
// Create a TransactionList with two different transactionIds
3260+
proto::TransactionList txList;
3261+
*txList.add_transaction_list() = tx1;
3262+
*txList.add_transaction_list() = tx2;
3263+
3264+
// When / Then
3265+
// This should throw because TransferTransaction (non-chunked) cannot have multiple transactionIds
3266+
EXPECT_THROW(
3267+
Transaction<TransferTransaction>::fromBytes(internal::Utilities::stringToByteVector(txList.SerializeAsString())),
3268+
std::invalid_argument);
3269+
}
3270+
3271+
//-----
3272+
// Security Test: FileAppend (chunked transaction) can have multiple transactionIds
3273+
// but they must all have the same transaction type.
3274+
TEST_F(TransactionUnitTests, FromBytesAllowsChunkedTransactionWithMultipleTransactionIdsOfSameType)
3275+
{
3276+
// Given
3277+
// Entry 1: FileAppend chunk 1
3278+
proto::TransactionBody txBody1;
3279+
txBody1.set_memo("FileAppend chunk 1");
3280+
txBody1.set_allocated_fileappend(new proto::FileAppendTransactionBody);
3281+
3282+
proto::TransactionID txId1;
3283+
txId1.mutable_accountid()->set_accountnum(201);
3284+
txId1.mutable_transactionvalidstart()->set_seconds(1700000010);
3285+
txId1.mutable_transactionvalidstart()->set_nanos(1);
3286+
txBody1.set_allocated_transactionid(new proto::TransactionID(txId1));
3287+
3288+
proto::SignedTransaction signedTx1;
3289+
signedTx1.set_bodybytes(txBody1.SerializeAsString());
3290+
proto::Transaction tx1;
3291+
tx1.set_signedtransactionbytes(signedTx1.SerializeAsString());
3292+
3293+
// Entry 2: FileAppend chunk 2 (same type, different transactionId - this is valid for chunks)
3294+
proto::TransactionBody txBody2;
3295+
txBody2.set_memo("FileAppend chunk 2");
3296+
txBody2.set_allocated_fileappend(new proto::FileAppendTransactionBody);
3297+
3298+
proto::TransactionID txId2;
3299+
txId2.mutable_accountid()->set_accountnum(201);
3300+
txId2.mutable_transactionvalidstart()->set_seconds(1700000011); // Different timestamp for chunk 2
3301+
txId2.mutable_transactionvalidstart()->set_nanos(1);
3302+
txBody2.set_allocated_transactionid(new proto::TransactionID(txId2));
3303+
3304+
proto::SignedTransaction signedTx2;
3305+
signedTx2.set_bodybytes(txBody2.SerializeAsString());
3306+
proto::Transaction tx2;
3307+
tx2.set_signedtransactionbytes(signedTx2.SerializeAsString());
3308+
3309+
// Create a TransactionList
3310+
proto::TransactionList txList;
3311+
*txList.add_transaction_list() = tx1;
3312+
*txList.add_transaction_list() = tx2;
3313+
3314+
// When / Then
3315+
// FileAppend is a chunked transaction type, so multiple transactionIds are allowed
3316+
// as long as they all have the same transaction type
3317+
EXPECT_NO_THROW(
3318+
Transaction<FileAppendTransaction>::fromBytes(internal::Utilities::stringToByteVector(txList.SerializeAsString())));
3319+
}
3320+
3321+
//-----
3322+
// Security Test: Even FileAppend (chunked) must reject mixed transaction types
3323+
TEST_F(TransactionUnitTests, FromBytesRejectsChunkedTransactionWithMixedTypes)
3324+
{
3325+
// Given
3326+
// Entry 1: FileAppend
3327+
proto::TransactionBody txBody1;
3328+
txBody1.set_memo("FileAppend");
3329+
txBody1.set_allocated_fileappend(new proto::FileAppendTransactionBody);
3330+
3331+
proto::TransactionID txId1;
3332+
txId1.mutable_accountid()->set_accountnum(201);
3333+
txId1.mutable_transactionvalidstart()->set_seconds(1700000010);
3334+
txId1.mutable_transactionvalidstart()->set_nanos(1);
3335+
txBody1.set_allocated_transactionid(new proto::TransactionID(txId1));
3336+
3337+
proto::SignedTransaction signedTx1;
3338+
signedTx1.set_bodybytes(txBody1.SerializeAsString());
3339+
proto::Transaction tx1;
3340+
tx1.set_signedtransactionbytes(signedTx1.SerializeAsString());
3341+
3342+
// Entry 2: Malicious CryptoTransfer hidden as "chunk 2"
3343+
proto::TransactionBody txBody2;
3344+
txBody2.set_memo("Hidden drain");
3345+
txBody2.set_allocated_cryptotransfer(new proto::CryptoTransferTransactionBody); // Different type!
3346+
3347+
proto::TransactionID txId2;
3348+
txId2.mutable_accountid()->set_accountnum(201);
3349+
txId2.mutable_transactionvalidstart()->set_seconds(1700000011);
3350+
txId2.mutable_transactionvalidstart()->set_nanos(1);
3351+
txBody2.set_allocated_transactionid(new proto::TransactionID(txId2));
3352+
3353+
proto::SignedTransaction signedTx2;
3354+
signedTx2.set_bodybytes(txBody2.SerializeAsString());
3355+
proto::Transaction tx2;
3356+
tx2.set_signedtransactionbytes(signedTx2.SerializeAsString());
3357+
3358+
// Create a TransactionList
3359+
proto::TransactionList txList;
3360+
*txList.add_transaction_list() = tx1;
3361+
*txList.add_transaction_list() = tx2;
3362+
3363+
// When / Then
3364+
// Even for FileAppend, mixed transaction types must be rejected
3365+
EXPECT_THROW(
3366+
Transaction<FileAppendTransaction>::fromBytes(internal::Utilities::stringToByteVector(txList.SerializeAsString())),
3367+
std::invalid_argument);
3368+
}
3369+
3370+
//-----
3371+
// Security Test: Directly exercise ChunkedTransaction constructor validation.
3372+
// Build a mixed-type transactions map and ensure chunked constructor rejects it.
3373+
TEST_F(TransactionUnitTests, ChunkedTransactionConstructorRejectsMixedTypes)
3374+
{
3375+
// Given
3376+
// Entry 1: FileAppend (expected chunked type)
3377+
proto::TransactionBody txBody1;
3378+
txBody1.set_memo("FileAppend chunk 1");
3379+
txBody1.set_allocated_fileappend(new proto::FileAppendTransactionBody);
3380+
3381+
proto::TransactionID txId1;
3382+
txId1.mutable_accountid()->set_accountnum(201);
3383+
txId1.mutable_transactionvalidstart()->set_seconds(1700000020);
3384+
txId1.mutable_transactionvalidstart()->set_nanos(1);
3385+
txBody1.set_allocated_transactionid(new proto::TransactionID(txId1));
3386+
3387+
proto::SignedTransaction signedTx1;
3388+
signedTx1.set_bodybytes(txBody1.SerializeAsString());
3389+
proto::Transaction tx1;
3390+
tx1.set_signedtransactionbytes(signedTx1.SerializeAsString());
3391+
3392+
// Entry 2: Different TransactionId and different type (CryptoTransfer)
3393+
// This simulates a smuggled chunk that should be rejected by ChunkedTransaction.
3394+
proto::TransactionBody txBody2;
3395+
txBody2.set_memo("Smuggled transfer");
3396+
txBody2.set_allocated_cryptotransfer(new proto::CryptoTransferTransactionBody);
3397+
3398+
proto::TransactionID txId2;
3399+
txId2.mutable_accountid()->set_accountnum(201);
3400+
txId2.mutable_transactionvalidstart()->set_seconds(1700000021);
3401+
txId2.mutable_transactionvalidstart()->set_nanos(1);
3402+
txBody2.set_allocated_transactionid(new proto::TransactionID(txId2));
3403+
3404+
proto::SignedTransaction signedTx2;
3405+
signedTx2.set_bodybytes(txBody2.SerializeAsString());
3406+
proto::Transaction tx2;
3407+
tx2.set_signedtransactionbytes(signedTx2.SerializeAsString());
3408+
3409+
std::map<TransactionId, std::map<AccountId, proto::Transaction>> transactions;
3410+
const auto txIdKey1 = TransactionId::withValidStart(AccountId(201), std::chrono::system_clock::from_time_t(1700000020));
3411+
const auto txIdKey2 = TransactionId::withValidStart(AccountId(201), std::chrono::system_clock::from_time_t(1700000021));
3412+
transactions[txIdKey1][AccountId(3)] = tx1;
3413+
transactions[txIdKey2][AccountId(3)] = tx2;
3414+
3415+
// When / Then
3416+
EXPECT_THROW(FileAppendTransaction{ transactions }, std::invalid_argument);
3417+
}

0 commit comments

Comments
 (0)