Since version with the features I'm going to present here was already officially released, I though it is high time to describe them.
If you are a witness or a node operator, it would be nice if you knew what some new configuration options do, but in the unlikely event of a flood attack on the network new features will protect your node just fine on default settings.
The resource that is most prone to being exhausted is
execution time, since its pool is very shallow compared to other resources. On the flip side it also comes back to normal much quicker than other pools after traffic gets lower.
For pretty much all history witnesses only allowed blocks of size 64kB
Isn't it a DDoS? - Yes, pretty similar, but no. Attacker might choke any API node by overcrowding it with calls, however it would have no effect on the network, especially since witness nodes usually don't have any APIs enabled (or even if they do, it is for local network, not the world). The flood attack is more complicated, because attacker needs to use valid transactions that will propagate through the network and reach witness nodes in order to increase size of mempool so much, that the hardware limits are reached and the node won't be able to process or produce blocks in time, leading to constant missed blocks, forks and eventually even to node shutdown. If attacker manages to finish enough witnesses, it might even stop the whole network. It is actually better for the attacker to bypass API with its limitations altogether and inject transactions from own nodes, because p2p allows for flooding multiple peers at once, increasing the chance of success.
To put it differently... I'm sure everyone heard about bots that copy-paste old posts trying to milk rewards. There is supposedly 17k+ of such accounts belonging to single user. Imagine each of those accounts sending one transaction for every single block for hours. Of course they don't have RC for that, there are other limitations in place as well, but it should help visualize the scale of traffic when talking about flood.
Let's continue. You might want to read that old article about process of handling transactions for more details, however here are selected definitions relevant in the context of this article:
Hive nodes are super fast when it comes to processing transactions (and we have not said the last word yet) - a node can validate far more transactions between blocks than can fit even in biggest allowed block. That speed is necessary, since it influences time needed for replay or synchronization, and during normal processing affects fork handling as well as simply leaves more time for answering API calls.
Everything started with issue #709. I wanted to have practical evidence to support the claim that we can't simply increase maximum allowed expiration time for transactions. That's why I've forced our dear admin to make me isolated mirrornet that I could run over with the attack.
By the way, preparation of such mirrornet takes far too much time. It would be hard to close the process in a script, given various parameters decided by the purpose of such mirronet, but we should at least have a list of steps to take, if only to avoid basic mistakes.
The mirrornet I was attacking was deployed on fast local network, three beefy servers hosting two witness nodes each. The attack was performed by three to eight colony enabled nodes hosted on my development machine (I could run more, but the effects I was looking for were showing already, so there was no point). I've checked situations with blocks of different sizes, from currently used 64kB to maximal allowed 2MB, also by changing the block size while the attack was ongoing.
Setup drastically changes behavior of the network. When at the start only three witness nodes were used, I could reach catastrophic network split when nodes stop talking to each other and go their separate merry ways (due to lowered required witness participation parameter needed to allow proper start of such network, but still). It is actually understandable - the more witnesses is on the same node, the chance for long forks when the node can keep producing without effectively communicating with other nodes also increases.
I suspect if the test was done in more realistic environment, the results could be different.
Observant reader will ask here about my development computer - is it some blade server monstrosity for me to be able to run 8 nodes at once during attack? Not at all. The nodes were not normal though. I did some heavy changes (as in impact of modifications, not actual amount of code that I've changed) so to avoid above mentioned problems. Also the
colonyplugin had to be modified, because in its normal state it self regulates precisely to avoid flooding. What iscolonyagain? - I'll write about it some day, but for the purpose of this article it is enough to know that it is a plugin that automatically produces set amount of random spammable transactions. There are some conditions that have to be met for it to work, and most important one is described below.
colony to work properly, it needs enough accounts that it can use (it detects them automatically). Attacker on mainnet would need to have their own accounts in suitable amount (and I can tell you here that 100k+ is needed for proper attack, the more the better, because even spammable transactions have their limits, and if you try to use only those with no limits, you are drastically increasing severity of next problem).Considering number of accounts and RC needed to perform the attack, doesn't that mean the attacker is playing against their own stake? - No, attacker only needs to be able to sign for that many accounts, not to own/have full control over them. There are currently at least three applications on Hive that have access to enough accounts of other users through authority redirections. I'm not suggesting any of them would actually abuse their position, however the mechanism is clear - attacker needs that many accounts and that much RC, but neither has to be theirs.
Flood attack consists of sending large amount of valid transactions to the network to accumulate postponed transactions in mempools on nodes in order to cause excessive RAM allocation and related effects once physical RAM is exhausted. Preventive measures introduced in 1.27.7 are described below.
The first and easiest solution is to limit size of mempool. The option for that is not surprisingly named max-mempool-size. Default is 100M. It used to be bigger, but first, the value is only related to cumulative size of raw transactions (the amount of space they take in blocks), not their whole memory footprint (pending transactions are wrapped with extra data, f.e. precomputed invariants), and second, after further protection mechanisms were introduced, it is actually hard to even reach that value.
When pending transactions are rewritten to new mempool after block is applied, node accumulates size of transactions. The reapplied transactions are always processed normally. Only when there is more pending transactions than the node can handle within 200ms time limit, it starts to look at size limit. All transactions that are left after configured size limit is exceeded are dropped completely. The amount of dropped transactions is reflected in block stats under new field .after.drop. Also the size of mempool after optional drop is given in new field .after.size. Because only postponed transactions are ever dropped, even the 0 is a legitimate value for max-mempool-size (the node is not dropping transactions that it can immediately reflect in state). Such setting is not recommended though, because it changes little in terms of memory allocation compared to default, while the node will definitely be dropping transactions that would otherwise be included in future blocks (even as soon as next block in case of 2MB blocks).
You can observe mechanism in action (with mempool set to 0) by running plugin_test --run_test=witness_tests/colony_basic_test. When test switches from 1MB blocks to 64kB blocks at block 85, colony overshoots with transaction generation before it self restricts for further blocks. All postponed transactions are dropped and the remaining reapplied transactions are enough to fill next nine blocks.
2025-01-13T16:39:50.801642 p2p_plugin.cpp:579 broadcast_block ] Broadcasting block #84 with 3813 transactions
2025-01-13T16:39:50.864914 witness_plugin.cpp:473 block_production_loo ] Generated block #84 with timestamp 2025-01-13T16:39:51 at time 2025-01-13T16:39:51
2025-01-13T16:39:50.877205 block_flow_control.cpp:113 on_worker_done ] Block stats:{"num":84,"lib":84,"type":"gen","id":"0000005430eae9a19da9203e5eb771ffc40fa017","ts":"2025-01-13T16:39:51","bp":"initminer","txs":3813,"size":1048517,"offset":-192868,"before":{"inc":3832,"ok":3832,"auth":0,"rc":0},"after":{"exp":0,"fail":0,"appl":177,"post":0,"drop":0,"size":47526},"exec":{"offset":-399946,"pre":65,"work":207013,"post":69938,"all":277016}}
2025-01-13T16:39:51.407240 witness_tests.cpp:1814 operator() ] Tx count for block #84 is 3813
2025-01-13T16:39:53.631009 block_producer.cpp:190 apply_pending_transa ] 3767 transactions could not fit in newly produced block (0 failed/expired)
2025-01-13T16:39:53.632602 p2p_plugin.cpp:579 broadcast_block ] Broadcasting block #85 with 238 transactions
2025-01-13T16:39:53.640959 witness_plugin.cpp:473 block_production_loo ] Generated block #85 with timestamp 2025-01-13T16:39:54 at time 2025-01-13T16:39:54
2025-01-13T16:39:53.842203 block_flow_control.cpp:113 on_worker_done ] Block stats:{"num":85,"lib":85,"type":"gen","id":"0000005570545aded1fb47f364a015097944a76a","ts":"2025-01-13T16:39:54","bp":"initminer","txs":238,"size":65433,"offset":-363926,"before":{"inc":3828,"ok":3828,"auth":0,"rc":0},"after":{"exp":0,"fail":0,"appl":2197,"post":0,"drop":1570,"size":609500},"exec":{"offset":-399943,"pre":60,"work":35957,"post":204914,"all":240931}}
2025-01-13T16:39:54.407674 witness_tests.cpp:1814 operator() ] Tx count for block #85 is 238
2025-01-13T16:39:56.632240 block_producer.cpp:190 apply_pending_transa ] 1971 transactions could not fit in newly produced block (0 failed/expired)
2025-01-13T16:39:56.633724 p2p_plugin.cpp:579 broadcast_block ] Broadcasting block #86 with 226 transactions
2025-01-13T16:39:56.642236 witness_plugin.cpp:473 block_production_loo ] Generated block #86 with timestamp 2025-01-13T16:39:57 at time 2025-01-13T16:39:57
2025-01-13T16:39:56.709327 block_flow_control.cpp:113 on_worker_done ] Block stats:{"num":86,"lib":86,"type":"gen","id":"00000056c314159c7f2a0231faf6401396e396dd","ts":"2025-01-13T16:39:57","bp":"initminer","txs":226,"size":65435,"offset":-362102,"before":{"inc":0,"ok":0,"auth":0,"rc":0},"after":{"exp":0,"fail":0,"appl":1971,"post":0,"drop":0,"size":544187},"exec":{"offset":-399945,"pre":62,"work":37781,"post":71315,"all":109158}}
2025-01-13T16:39:57.408108 witness_tests.cpp:1814 operator() ] Tx count for block #86 is 226
Above preventive measure is enough to protect against main effect of flood attack, but there is also problem of RC. Flood leads to overconsumption of resources and that leads to increase of RC costs. As a result weak accounts might be priced out of transacting or simple transactions will cost them solid chunk of their manabar.
That is actually the real problem, because weak account that consumed all their mana won't be able to transact for long time even after attack stops.
Full prevention is not possible, but we can lessen the impact to a degree by taking advantage of the difference between normal accounts and attacker accounts during flood.
Normal users, even if they have a burst of activity (f.e. reading and then voting on many articles in short succession), from the blockchain perspective will have considerable gap between transactions. Attacker accounts on the other hand might be sending new transactions while their previous ones are still in mempool even among reapplied (depending on intensity of the attack and amount of accounts that take part in it). What if the node demanded them to have 10, 20 or more times the RC required to pay for transaction? If normally a transaction costs let's say 100M RC, even the free account should be able to "afford" a surcharge of 50 times (when at full mana). But attacker accounts will be burning through their RC, so they are guaranteed to be far from full. Moreover with potentially couple of transactions pending from the same account, drastic increase of RC cost will accumulate and burden their manabars.
But we can't actually consume more RC. First, we wanted to prevent/reduce that very effect the increased RC costs have on weak accounts. Second, flood is not guaranteed to affect all nodes in the network with exact same intensity. The mechanism used here works only on new transactions and partially on pending ones, when state is still not part of consensus. Once the transaction reaches block, it consumes normal amount of RC, same for all nodes. So, normal account will be temporarily charged exorbitant amount of RC, but once transaction reaches block, the cost will be normal, so they can continue to transact. Even if the temporary cost is too high, they can wait a bit for the attack to lessen and then resend their transaction. In other words either everything is normal or a mild inconvenience.
Ok, I've oversimplified a lot. Of course the main effect of flood is that transactions that users send have to wait a lot in mempool before they are included in the block or dropped due to expiration (which is typically quite short - depends on wallet settings). But that can only be addressed by increasing block size.
On the other hand attacker accounts will need to afford exorbitant cost of multiple transactions that they spam, which at some point should prevent them from spamming more. Some of their transactions will be bounced back due to lack of RC, which is what the network wants - less spam propagated through. Even if they resend the very same transactions, the intensity of the attack is lessened.
There are two new configuration options for this mechanism:
rc-flood-level - default 20. It regulates how many full blocks worth of transactions can be present in mempool before the mechanism turns on. F.e. with default 20 and 64kB blocks it means the mechanism will only start charging extra when there is over 1.25MB in mempool.rc-flood-surcharge - default 10000 (100%). It tells how much extra to charge for each block worth of transactions in mempool. F.e. with default values when there is 22*64kB in mempool, each transaction RC cost will be multipled by 3 (2 blocks above flood level times 100% extra plus normal cost).Here is how it works. When new transaction is validated, node already knows its current size of mempool, in particular how many full blocks would it take to empty pending transactions. When RC is about to be charged for new transaction, surcharge is calculated and the normal cost plus extra is taken out of RC payer's manabar. When the account is of a normal user, unless they are running on fumes (in which case they could use some cooldown, especially during flood), they won't notice anything (maybe if they read their manabar state immediately after transacting). If they can't afford the surcharge, the message will be clear, something like:
Account: freeaccount has 3049953790 RC, needs 81266164 RC with 4063308200 flood prevention surcharge. Please wait to transact, power up HIVE or ask your witnesses to increase block size to deal with increased traffic.
On the other hand, the attacker account will likely have some of their previous transactions in pending, if those are reapplied (or just added to pending as new for the same block) they will still be affected by the surcharge on them.
When transaction finally reaches block surcharge is not applied, only normal RC cost.
Surcharge can occasionally affect transactions that are already in pending, although that should rarely happen. Since list of pending transactions typically shortens from the front after each block (mainly by those that were included in block, but also those that expired while waiting or became invalid due to changes in state), transactions that survived certain level of surcharge, when reapplied they are facing smaller surcharge (because size of mempool is calculated anew from zero and accumulated size will be smaller with less transactions in front). Only in case of drastic changes in RC costs (that can happen when some pool is near exhaustion) or after fork switch when significant portion of transactions that were popped from the outgoing fork happened to not be included in incoming fork (popped transactions are effectively pushed to the front of pending list). In case the payer cannot afford surcharge for transactions reapplied from pending, those transactions will be dropped.
So, we've already prevented main effect of spam on RAM consumption and reduced rate of spam with RC surcharge. There is one more way the attacker could potentially use the attack - to prevent witnesses from sending out witness parameters such as change in allowed block size (increasing block size, even if only temporary, can help to unload pending transactions) or price feed (so attacker could aim to get better price on their pending conversions).
Increasing block size does help to process more transactions and makes the attack harder, but it is a double edged sword. If the attack succeeds in causing forks, then a fork of a single 2MB block is bigger than very long fork of 64kB blocks and switching fork takes proportionally longer making the impact stronger. Also big blocks mean more transactions will consume resources budgeted for single block, having stronger impact on RC costs.
Preventing problem of witnesses not being able to transact is quite simple - since there is small set of operations that can affect blockchain parameters (feed_publish, witness_update and witness_set_properties) and they are all relevant when sent by top witnesses, let's privilege them, that is, in case of flood put them in front of pending list (and also don't apply surcharge).
During testing I've also noticed that before we start thinking about really increasing allowed block sizes, I mean to at least 1MB (which we don't need currently, but who knows what the future will bring), we have to have a good look at internal p2p parameters and make them more dynamic. F.e. during syncing when the network has partially filled 64kB blocks, we want to load several thousand of them all at once, but it is not a good idea when those blocks are 2MB each :o)
Images generated with Ideogram