-
Notifications
You must be signed in to change notification settings - Fork 3.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Prevent Spam Txs #4695
Comments
Indeed this is exploitable. But I'm not sure the suggested solution is viable: I would strive to think of a way we can handle this automatically in the |
Yea agreed, if the coins that a specific msg is going to deduct is obviously calculatable the above solution isn't a problem. If there is complicated state logic involved in trying to figure out the funds to deduct from an account, this solution becomes quite ugly. |
If trying to deduct both the tx business funds and corresponding fees from an account in CheckTx to preclude spam txs, the msg handler should be called to get that fund amounts(duplicate workload in CheckTx and DeliverTx) or some additional business logic is required to run against different msg types. |
Spam txs can also cheaply take up the full block with fees set to zero if any bonded validator configs its MinGasPrice to the default empty string. |
We want to keep the BaseApp semantics the same more or less -- i.e. we want to avoid having to execute the messages in CheckTx. I was thinking more along the lines of having CheckTx state be updated to reflect the changes in DeliverTx after a tx's execution...somehow.
This is a different matter entirely. It's being discussed in #4527. |
I would like to share my idea to address this issue (Which might make real troubles) |
Some msg-specific logic is required to compute the exact tx funds either in
|
@anagnoresis I'm not so sure it'll be that simple. For starters, that would add an additional field and complicate the UX. Secondly, not all messages are so simple that having a Just spitballing here, but what if we set the |
@alexanderbez I think that will work. So after any successful DeliverTx, we set the This would make sure that previous spends in the same block will cause fee-deduction to fail on future tx's which is the problem we want to solve. I like this solution 👍 |
I think it will be useful but not that much, setting the Moreover, spam txs will be reaped from mempool into proposals before |
Two points, TL;DR design of cosmos-sdk was that fees were sufficient to remove spam CheckTx is generally rerun after every commit. Using the check state (which is a throw-away buffer from the last commit with the checktx applied). This is good. The very conscious decision in the cosmos-sdk design was to make CheckTx as fast as possible, which was only to ensure there were enough fees, nothing about whether the tx would succeed. Even if the tx fails, they must pay fees, and the fees and nonce are updated in the CheckTx state before checking the next transactions. Thus, fees is supposed to be enough to limit spam. TL;DR there are other approaches, easy to run full tx every check In weave we allow every handler to register separate Check and Deliver methods, to allow execution of anything in Check. Nonce and fees are handled by the middleware (ante handler), but the handler may just check the message is valid, or update the check state. For most messages we do update the state, and thus multiple sendtx would not make it into mempool unless there were enough tokens in the account for all. In cosmos-sdk, this could be done easily as well, but removing the short-circuit (return on Check) in the antehandler/runTx, and passing all check and deliver tx to the handlers. By default, they would update the check state, and any handler would have to explicitly add separate logic for I personally agree with this change, but the main question is the design direction - do we want heavy checktx and clean blocks, or just allow anyone who can pay fees. I will also note that the heaviest sections of the transaction execution are signature verification (which is currently performed on check) and committing the iavl to disk (never done on check). Reading and writing to an in-memory cache for the changes to state should be a relatively minor cost (at least that was my basic results from benchmarking on weave, which is a similar design and also golang sdk using the iavl storage library). |
I think it would be great if there was a process for external requests to the sdk (that have passed some threshold) to enter design discussions with core sdk engineers. Many times these small changes have much larger implications in the design. And the sdk needs a consistent overarching design philosophy... all changes must fit inside it or open conversations to modify the design philosophy (also for other issues moving forward) |
This is the intended behavior, but it won't happen in the example i posted above.
This should be addressed in the future by checking if CheckTx is a Recheck (ABCI now allows this). This would make us only verify the signature on the first CheckTx for a given tx, and not every single time a Commit occurs |
Yes, the spam txs won't pay any fees to pass The worst case is that only the first And it turns out to be one valid transaction and thousands of failed spam txs per block with only a very small amount of fee deducted from the sender's account when the account of the receiver also belongs to the same sender.
|
Okay, fees are not deducted from the check state sent to the next checktx? I would actually recommend taking a step back and looking at the whole runTx/runMsg/anteHandler pipeline and seeing if this can be cleaned up to resolve this issue and others (especially extensibility and error reporting) |
This is not true. Fees are deducted in CheckTx states. Here is a test case that demonstrates current behavior in how you can spam: func TestSpamTxs(t *testing.T) {
mapp := getMockApp(t)
origCoins := sdk.NewCoins(sdk.NewInt64Coin("stake", 150))
acc1 := &auth.BaseAccount{
Address: addr1,
Coins: origCoins,
}
acc2 := &auth.BaseAccount{
Address: addr2,
Coins: sdk.NewCoins(),
}
// initialize genesis
mock.SetGenesis(mapp, []auth.Account{acc1, acc2})
// get first account
ctxCheck := mapp.BaseApp.NewContext(true, abci.Header{Height: mapp.LastBlockHeight() + 1})
_acc1 := mapp.AccountKeeper.GetAccount(ctxCheck, addr1)
// generate valid tx
origAcc1Num := _acc1.GetAccountNumber()
origAcc2Seq := _acc1.GetSequence()
fee := auth.StdFee{
Amount: sdk.NewCoins(sdk.NewInt64Coin("stake", 1)),
Gas: 100000,
}
sendMsg := types.NewMsgSend(addr1, addr2, sdk.NewCoins(sdk.NewInt64Coin("stake", 100)))
validTx := mock.GenTxWithFee(
[]sdk.Msg{sendMsg},
fee,
[]uint64{origAcc1Num},
[]uint64{origAcc2Seq},
priv1,
)
// begin block
header := abci.Header{Height: mapp.LastBlockHeight() + 1}
mapp.BeginBlock(abci.RequestBeginBlock{Header: header})
// check valid tx (CheckTx)
checkTxRes := mapp.Check(validTx)
require.Equal(t, sdk.CodeOK, checkTxRes.Code, checkTxRes.Log)
// deliver valid tx (DeliverTx)
deliverTxRes := mapp.Deliver(validTx)
require.Equal(t, sdk.CodeOK, deliverTxRes.Code, deliverTxRes.Log)
// validate CheckTx state
ctxCheck = mapp.BaseApp.NewContext(true, abci.Header{Height: mapp.LastBlockHeight() + 1})
_acc1 = mapp.AccountKeeper.GetAccount(ctxCheck, addr1)
require.Equal(t, _acc1.GetSequence(), origAcc2Seq+1) // => 0 + 1 = 1
require.Equal(t, _acc1.GetCoins().String(), origCoins.Sub(fee.Amount).String()) // => 150 - 1 (fee) = 149
// validate DeliverTx state
ctxDeliver := mapp.BaseApp.NewContext(false, abci.Header{Height: mapp.LastBlockHeight() + 1})
_acc1 = mapp.AccountKeeper.GetAccount(ctxDeliver, addr1)
require.Equal(t, _acc1.GetSequence(), origAcc2Seq+1) // => 0 + 1 = 1
require.Equal(t, _acc1.GetCoins().String(), sdk.NewCoins(sdk.NewInt64Coin("stake", 49)).String()) // 150 - 1 (fee) - 100 (send) = 49
for i := 1; i < 49; i++ {
// generate garbage (spam) tx
spamTx := mock.GenTxWithFee(
[]sdk.Msg{sendMsg},
fee,
[]uint64{origAcc1Num},
[]uint64{uint64(i)},
priv1,
)
// check valid tx (CheckTx)
checkTxRes = mapp.Check(spamTx)
require.Equal(t, sdk.CodeOK, checkTxRes.Code, checkTxRes.Log)
// deliver valid tx (DeliverTx)
deliverTxRes = mapp.Deliver(spamTx)
require.NotEqual(t, sdk.CodeOK, deliverTxRes.Code, deliverTxRes.Log)
// validate CheckTx state
ctxCheck = mapp.BaseApp.NewContext(true, abci.Header{Height: mapp.LastBlockHeight() + 1})
_acc1 = mapp.AccountKeeper.GetAccount(ctxCheck, addr1)
require.Equal(t, _acc1.GetSequence(), uint64(i+1))
require.Equal(t, _acc1.GetCoins().String(), sdk.NewCoins(sdk.NewInt64Coin("stake", int64(149-i))).String())
}
// At this point, the block has been filled with 1 valid tx and 48 spam txs.
// However, each spam tx still does pay fees!!!
ctxDeliver = mapp.BaseApp.NewContext(false, abci.Header{Height: mapp.LastBlockHeight() + 1})
_acc1 = mapp.AccountKeeper.GetAccount(ctxDeliver, addr1)
require.Equal(t, _acc1.GetCoins().String(), sdk.NewCoins(sdk.NewInt64Coin("stake", 1)).String())
mapp.EndBlock(abci.RequestEndBlock{})
mapp.Commit()
} So while it may be true that you are able to fill up blocks with spam txs, you do it at a cost -- paying fees. |
Great investigation, Bez, and how I thought the sdk was designed. We can debate the design, but the implementation lives up to spec 😄 There are two options:
The second one is more computation cost for more protection from malicious users not running a validator. In either case, a validator may include any tx (even unparseable ones) in the block. They will all return errors in DeliverTx, but will make it into the history. (Note that a validator doesn't even need to pay fees to include spam in any block they propose) It would be good to look at the threat model and if deeper validation in CheckTx is worth it. But at least we are certain that fees are functioning as an anti-spam mechanism and the response should be to raise the min gas price if you don't like garbage tx. |
@bez @ethanfrey the issue is that the fees are being deducted from a stale balance. So in bez's example above, this isn't a problem since the current balance still happens to have enough funds to pay the subsequent fees. Here's a test case that showcases the issue a bit more clearly imo: func TestSpamTxs(t *testing.T) {
mapp := getMockApp(t)
origCoins := sdk.NewCoins(sdk.NewInt64Coin("stake", 101))
acc1 := &auth.BaseAccount{
Address: addr1,
Coins: origCoins,
}
acc2 := &auth.BaseAccount{
Address: addr2,
Coins: sdk.NewCoins(),
}
// initialize genesis
mock.SetGenesis(mapp, []auth.Account{acc1, acc2})
// get first account
ctxCheck := mapp.BaseApp.NewContext(true, abci.Header{Height: mapp.LastBlockHeight() + 1})
_acc1 := mapp.AccountKeeper.GetAccount(ctxCheck, addr1)
// generate valid tx
origAcc1Num := _acc1.GetAccountNumber()
origAcc2Seq := _acc1.GetSequence()
fee := auth.StdFee{
Amount: sdk.NewCoins(sdk.NewInt64Coin("stake", 1)),
Gas: 100000,
}
sendMsg := types.NewMsgSend(addr1, addr2, sdk.NewCoins(sdk.NewInt64Coin("stake", 100)))
validTx := mock.GenTxWithFee(
[]sdk.Msg{sendMsg},
fee,
[]uint64{origAcc1Num},
[]uint64{origAcc2Seq},
priv1,
)
// begin block
header := abci.Header{Height: mapp.LastBlockHeight() + 1}
mapp.BeginBlock(abci.RequestBeginBlock{Header: header})
// check valid tx (CheckTx)
checkTxRes := mapp.Check(validTx)
require.Equal(t, sdk.CodeOK, checkTxRes.Code, checkTxRes.Log)
// deliver valid tx (DeliverTx)
deliverTxRes := mapp.Deliver(validTx)
require.Equal(t, sdk.CodeOK, deliverTxRes.Code, deliverTxRes.Log)
// validate CheckTx state
ctxCheck = mapp.BaseApp.NewContext(true, abci.Header{Height: mapp.LastBlockHeight() + 1})
_acc1 = mapp.AccountKeeper.GetAccount(ctxCheck, addr1)
require.Equal(t, _acc1.GetSequence(), origAcc2Seq+1) // => 0 + 1 = 1
require.Equal(t, _acc1.GetCoins().String(), origCoins.Sub(fee.Amount).String()) // => 101 - 1 (fee) = 100
// validate DeliverTx state
ctxDeliver := mapp.BaseApp.NewContext(false, abci.Header{Height: mapp.LastBlockHeight() + 1})
_acc1 = mapp.AccountKeeper.GetAccount(ctxDeliver, addr1)
require.Equal(t, _acc1.GetSequence(), origAcc2Seq+1) // => 0 + 1 = 1
require.Equal(t, _acc1.GetCoins().String(), sdk.NewCoins().String()) // 101 - 1 (fee) - 100 (send) = 0
for i := 1; i < 100; i++ {
// generate garbage (spam) tx
spamTx := mock.GenTxWithFee(
[]sdk.Msg{sendMsg},
fee,
[]uint64{origAcc1Num},
[]uint64{uint64(i)},
priv1,
)
// check valid tx (CheckTx)
checkTxRes = mapp.Check(spamTx) // 101 - 1 (fee1) - i * fee => PASSES
require.Equal(t, sdk.CodeOK, checkTxRes.Code, checkTxRes.Log)
// deliver valid tx (DeliverTx)
deliverTxRes = mapp.Deliver(spamTx) // 0 - fee => FAILS
require.NotEqual(t, sdk.CodeOK, deliverTxRes.Code, deliverTxRes.Log)
}
// At this point, the block has been filled with 1 valid tx and 99 spam txs.
// I could spam with arbitrary number of spamTx's from an empty account so long as it is in same block as first tx
// Each SpamTx has failed DeliverTx but passes CheckTx.
// Thus, a proposer may unintentionally fill up a block with these spam txs.
// Spammer doesn't pay for all the spam tx's because there isn't any funds in his account after the first DeliverTx anyway
ctxDeliver = mapp.BaseApp.NewContext(false, abci.Header{Height: mapp.LastBlockHeight() + 1})
_acc1 = mapp.AccountKeeper.GetAccount(ctxDeliver, addr1)
require.Equal(t, _acc1.GetCoins().String(), sdk.NewCoins().String())
mapp.EndBlock(abci.RequestEndBlock{})
mapp.Commit()
} |
Indeed, you are correct @AdityaSripal. I wonder if simply setting the |
@alexanderbez why do we even need to maintain two separate states anymore? Let's just have a single state that we maintain, just don't write at the end of the Will try implementing to see if it will work |
@AdityaSripal If having just a single state without persisting the |
DeliverTx and CheckTx have wildly different lifetimes. Starting at commit of block H.
It is true that a few random CheckTx may be called in parallel with DeliverTx (but not during the commit phase, which has a lock). But the main execution of CheckTx is AFTER Commit and BEFORE BeginBlock. Before removing things like the two states (we need that so the 50 CheckTx called before BeginBlock have state that build upon each other), I think it would be good to draw out the lifecycle of the Tx. github.com/iov-core/weave has this working without issues for a long time. and a relatively simple solution using two states Deliver and Check. We just write state updates in CheckTx if the Tx doesn't error. If it does error, it doesn't enter mempool. I mention this, as I think many cosmos-sdk devs can learn from a simpler weave to understand the flows. And I am concerned that in a rush to address on bug, some important architecture will be broken. |
@AdityaSripal To really convince me with your example above... Use this line: And if it can still get 100 CheckTx with a few of 1stake to pass, then the problem is demonstrated. Right now you have plenty to pay them all |
I do agree with this option. Specially if you are in case going to run smart contracts in future. Then running smart contracts in check-tx phase is troublesome. Let it be as it is now. Fee is a good mechanism for preventing spams. |
I think the proper solution to this is that we should be doing CheckTxs in order, and then the DeliverTxs. There is an open issue on Tendermint or the SDK that in fact, validators should run CheckTx before pre-voting on a block. Then we don't have to run the ante-handler in the DeliverTx. @ebuchman do you know where this issue is? |
@sunnya97 If If so, running
|
@ethanfrey we've had folks work pretty hard on a tx lifecycle doc found here. That being said, I'd recommend we not jump to any immediate proposed solutions as there are some major potential changes upcoming to ABCI/mempool design that we should take into consideration, namely:
Not sure I follow...Do you mean executing all the |
When using cosmos-sdk to develop our blockchain, we use a quick patch to alleviate this problem. It is not a final and perfect solution, but it is effective and easy to implement. We add a cache to record the most recent unconfirmed transactions (i.e. within 15 minutes) which passed CheckTx and have not be included in a committed block. Using this cache, we can require that one account can have at most one unconfirmed transaction, during a given period (for example, 1 minute). If extra transaction comes, it will be rejected by CheckTx. Such a cache can be implemented totally in the upper-level application, without modifying the ABCI protocol or the code in cosmos-sdk&terdermint. And this cache is not part of the consensus, so validators can choose to disable or enable it according to their own opinion. With cosmos-sdk, multiple Msg within one transaction is easier to use than multiple unconfirmed transactions. So forbiding multiple unconfirmed transactions will not hurt user experience. |
I can see how that works @wangkui0508 but I don't think that's a viable solution, at least in the long term. It doesn't seem like the best idea to introduce these kinds of limitations in CheckTx. |
It is a temporary patch to alleviate the problem, in the short term. I think DeliverBlock would be a better solution in the long term. |
I have an idea and I like to share with you. Merging AnteHandler and ModuleHandler. Anytime a new block is going to create, we need to create a snapshot of current state and execute transactions on this snapshot. func (c Context) KVStore(key StoreKey) KVStore {
if c.CheckTx {
return c.CachedMultiStore()...
} else {
return c.MultiStore()...
}
} |
@b00f the current design already works on a cached-view. How does your proposal work insight of that and how does it help with spam? |
Maybe cache is not a good term here. I try to explain my though. Fully executing tx will help to address this issue. I tried to find an answer for this question: @alexanderbez Do you think this can fix this issue? |
How we can fully execute txs in check_tx phase but at the same time don't change the state of blockchain. —— Since the current design already works on a cached-view, this target can be easily achieved. The problem is, we want check_tx to be light-weighted and run fast. So "fully execute txs in check_tx" is a bad idea. |
Correct, like @wangkui0508 pointed out, we intentionally avoid executing the entire tx during CheckTx. |
If you don't fully execute a tx, how do you want to prevent spamming? A spam transaction will be validated/executed by one node and since it's invalid it will be dropped from mempool. I don't say this is the best solution, but compare to other solutions it is more practical without bringing more complexity to the code. |
Spam is currently prevented using the (min) fee mechanism. Although, this only holds if all the validators have some sane minimum fee set. |
I don't like to debate but minimum-fee has not solved the issue. It just mitigated it. |
To some extent, correct, hence we're having this discussion to discuss ideas. |
@b00f Agreed. To compute the accurate fund deduction, fully execution of a tx is required. And minimum-fee only does a little favor to prevent this type of spam txs. Since a light-weighted |
Actually not sure we have a good dedicated issue for this, but related are https://github.com/tendermint/tendermint/issues/2639 and tendermint/tendermint#3322 (comment) |
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. |
Still applicable |
Digging out - seams to be more relevant now |
closing in favor of #8917 |
Summary of Bug
Brought up by @gamarin2 from user on Telegram.
checkState
does not reflect any account balance updates from msg processing. This can be exploited to cheaply spam tx's in full block.Example provided by @gamarin2:
checkTx(Tx1)
only updatedAccount1
's balance by deductingfeeAmount
rather than deducting byfeeAmount
plus the 100 atoms that would have been deducted by msg processing.This allows future tx's within the same block to pass
CheckTx
so long as the fee is not greater than101atoms - fee
. Even though Account1's true balance at that point is1atom - fee
.This can be exploited by submitting an arbitrary amount of garbage tx's like
Tx2
that all have fees that are larger thanAccount1
's balance. They will still passCheckTx
so long as they are proposed in same block asTx1
, however the fees inTx2
cannot be collected by validators because the Account doesn't actually hold those funds.Thus, entire block can be filled by spammer for the cost of the
fee
submitted inTx1
.Version
master: 1c9a188
Steps to Reproduce
Start
gaiad
node with slow block time and 2 genesis accounts{Acc1: 101 atoms, Acc2: 1atoms}
Submit
Tx1 (fee = 0.1atom)
andTx2 (fee = 1.1 atom)
such that they get committed in same block.CheckTx
ofTx2
will not fail. HoweverdeliverState
will.Querying Balances after block:
{Acc1: 0.9atom, Acc2: 101atoms}
Suggested Solution
This is an issue for any msg that removes coins from an account. Thus,
checkState
must reflect all account updates from msg.Use modular ante-handler (#4572) to fix the problem:
Each module knows which module msgs update account balances.
Thus, each module
AnteHandler
checks to see if any of these msgs are embedded inside the tx. If that msg is in tx andctx.IsCheckTx
is true, then antehandler removes coins from account.This allows
checkState
to be updated with account balances from msg processing without makingCheckTx
do any unnecessary state updates that msg handler might perform.For Admin Use
The text was updated successfully, but these errors were encountered: