Printing Infinite Money on the Cosmos Blockchain
A uint256 underflow in cosmos blockchain
Background
I was bored, and spent a week reading cosmos/evm, the module that lets a Cosmos SDK chain run an Ethereum execution environment. It is what most Cosmos chains use when they want EVM compatibility without giving up the SDK modules they already have.
The part I was really interested in is precompiles. Basically, a precompile is an address in the EVM address space that is not backed by bytecode. When you CALL it, the node runs native Go instead. In cosmos/evm the precompiles are the bridge to the SDK.
That part where I expected to find bugs (and i did), because it forces two very different ideas about state to line up on every single call. Ethereum thinks an account is an address, a nonce, and one uint256 balance. The Cosmos SDK thinks an account is an interface, and the balance is a multi denomination coin set living in a different module entirely.
The whole point of this post is about what happens when those two ideas disagree by exactly the amount you are about to delegate.
Vesting math
You always have to start with what the SDK gives you. x/auth/vesting defines accounts holding coins that unlock over time. The one that matters is ContinuousVestingAccount.
Why? Because it is created with an original allocation \(V\), a start time \(t_0\) and an end time \(t_1\). At any time \(t\) the amount that has vested is a piecewise linear ramp:
\[\mathrm{vested}(t) \;=\; \begin{cases} 0 & t \le t_0 \\[6pt] V \cdot \dfrac{t - t_0}{t_1 - t_0} & t_0 < t < t_1 \\[6pt] V & t \ge t_1 \end{cases}\]and the rest is still vesting:
\[\mathrm{vesting}(t) \;=\; V - \mathrm{vested}(t)\]The account also carries two counters, \(D_v\) for delegated_vesting and \(D_f\) for delegated_free, recording how much of the current delegation came out of locked coins and how much came out of free ones. With those, the locked amount is:
1
2
3
4
func (bva BaseVestingAccount) LockedCoinsFromVesting(vestingCoins sdk.Coins) sdk.Coins {
lockedCoins := vestingCoins.Sub(vestingCoins.Min(bva.DelegatedVesting))
return lockedCoins
}
which is a clamped subtraction:
\[\mathrm{locked}(t) \;=\; \max\bigl(0,\; \mathrm{vesting}(t) - D_v\bigr)\] \[\mathrm{spendable}(t) \;=\; \mathrm{total} - \mathrm{locked}(t)\]x/bank refuses to move locked coins on a transfer. and yes, this is the entire point of vesting. delegation is different, and this is the detail everything else rests on:
To make this clearer: imagine an account is holding 110e18 with 100e18 still vesting, the SDK’s position is that the total is 110e18, the locked portion is 100e18, the spendable portion is 10e18, and the delegatable portion is the whole 110e18
Two ledgers
Now let’s look at the EVM:
1
2
3
4
5
6
7
8
9
10
11
func (k *Keeper) GetAccount(ctx sdk.Context, addr common.Address) *statedb.Account {
ctx, span := ctx.StartSpan(tracer, "GetAccount", trace.WithAttributes(attribute.String("address", addr.Hex())))
defer span.End()
acct := k.GetAccountWithoutBalance(ctx, addr)
if acct == nil {
return nil
}
acct.Balance = k.SpendableCoin(ctx, addr)
return acct
}
SpendableCoin forwards to the bank wrapper:
1
2
3
4
5
6
func (k *Keeper) SpendableCoin(ctx sdk.Context, addr common.Address) *uint256.Int {
ctx, span := ctx.StartSpan(tracer, "SpendableCoin", trace.WithAttributes(attribute.String("address", addr.Hex())))
defer span.End()
cosmosAddr := sdk.AccAddress(addr.Bytes())
coin := k.bankWrapper.SpendableCoin(ctx, cosmosAddr, types.GetEVMCoinDenom())
}
If the EVM reported the total, a vesting account could call transfer() on a plain EVM address and walk its locked coins straight out, and vesting would be worthless.
It also means the two numbers differ by construction. Write \(\beta\) for the balance the EVM sees:
\[\beta \;=\; \mathrm{total} - \mathrm{locked} \;\le\; \mathrm{total}\]with equality if and only if \(\mathrm{locked} = 0\). For a BaseAccount the lock is always zero, the two ledgers agree, and nothing below can happen. For a vesting account mid schedule the gap is exactly the locked amount.
maybe this image can explain it better:
The precompile path
now we put them together. the victim calls the staking precompile:
staking.delegate(victim, "test", 100e18);
the precompile calls the staking keeper, which calls bank.DelegateCoins(victim, 100e18). The SDK does the right thing, moves 100e18 into the bonded pool and updates \(D_v\). x/bank emits an event carrying the full amount:
1
2
3
EventTypeCoinSpent
amount = 100e18
spender = <victim>
The EVM knows nothing about any of this. Its stateObject still holds the value it loaded in GetAccount before the call started. So cosmos/evm connect both by replaying bank events into the StateDB after the precompile returns:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
case banktypes.EventTypeCoinSpent:
spenderAddr, err := ParseAddress(event, banktypes.AttributeKeySpender)
if err != nil {
return fmt.Errorf("failed to parse spender address from event %q: %w", banktypes.EventTypeCoinSpent, err)
}
if bh.bankKeeper.BlockedAddr(spenderAddr) {
continue
}
amount, err := ParseAmount(event)
if err != nil {
return fmt.Errorf("failed to parse amount from event %q: %w", banktypes.EventTypeCoinSpent, err)
}
stateDB.SubBalance(common.BytesToAddress(spenderAddr.Bytes()), amount, tracing.BalanceChangeUnspecified)
amount is measured against the bank ledger. stateDB holds the EVM ledger. The only guard on the path is BlockedAddr, which returns true for module accounts, and a vesting account is not a module account, so the continue never fires.
There is no check that amount <= stateDB.GetBalance(spender).
The subtraction
1
2
3
4
5
6
func (s *stateObject) SubBalance(amount *uint256.Int) uint256.Int {
if amount.IsZero() {
return *(s.Balance())
}
return s.SetBalance(new(uint256.Int).Sub(s.Balance(), amount))
}
What happens if you subtract two uint256 values whose answer is supposed to be negative? solidity (prior to version 0.8.x) doesn’t check for over/underflows. that means that if you’re if you’re trying to set it to less than 0 (or more than \(2^{256} -1\) it’s going to wrap around \(0-1 = 2^{256}-1\) and \((2^{256}-1) +1 = 0\). The way we used to deal with this prior to 0.8 is to use the SafeMath library.
uint256.Int.Sub is arithmetic in the ring \(\mathbb{Z}/2^{256}\mathbb{Z}\). It does not saturate, it does not panic, it does not return an error. The library ships a SubOverflow variant that returns the result plus a bool.
Upstream go-ethereum gets away with the unchecked version because in vanilla geth every balance decrement is preceded by a CanTransfer check inside the interpreter, so sufficiency is guaranteed before you ever reach the state object. That invariant does not hold on this path, because the debit did not come from the EVM. It came from a keeper, and it arrived as an event.
So with \(V\) delegated against a visible balance \(\beta\), the new state object balance is:
\[B \;\equiv\; \beta - V \pmod{2^{256}}\]and since we have arranged for \(\beta < V\):
\[B \;=\; 2^{256} - (V - \beta)\]Surviving the commit
At this point nothing is persisted. It is a dirty state object in a journal. The interesting question is whether it survives the write.
It does. Keeper.SetBalance is the last thing standing between the wrapped value and the store:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (k *Keeper) SetBalance(ctx sdk.Context, addr common.Address, amount *uint256.Int) (err error) {
if amount == nil {
return nil
}
ctx, span := ctx.StartSpan(tracer, "SetBalance", trace.WithAttributes(attribute.String("address", addr.Hex()), attribute.String("amount", amount.String())))
defer func() { evmtrace.EndSpanErr(span, err) }()
cosmosAddr := sdk.AccAddress(addr.Bytes())
coin := k.bankWrapper.SpendableCoin(ctx, cosmosAddr, types.GetEVMCoinDenom())
if amount.ToBig().Cmp(coin.Amount.BigInt()) > 0 && k.bankWrapper.BlockedAddr(cosmosAddr) {
return errorsmod.Wrapf(errortypes.ErrUnauthorized, "%s is not allowed to receive funds", cosmosAddr)
}
locked := k.bankWrapper.LockedCoins(ctx, cosmosAddr)
newBalance := new(big.Int).Add(amount.ToBig(), locked.AmountOf(types.GetEVMCoinDenom()).BigInt())
return k.bankWrapper.SetBalance(ctx, cosmosAddr, newBalance)
}
And here the funny part: two things in here look like protections, but neither one is.
the first computes exactly the condition we are in, and then throws it away. The second is EVEN worse, because amount.ToBig() reinterprets the wrapped value as an unsigned big.Int and sdkmath.Int is arbitrary precision, so there is no 256 bit ceiling to wrap back into:
Yes, the mitigation makes the number bigger..
And what is \(\mathrm{locked}\) here? We just delegated the entire allocation, so \(D_v = \mathrm{vesting}(t)\), and the clamp collapses:
\[\mathrm{locked}(t) \;=\; \max\bigl(0,\; \mathrm{vesting}(t) - D_v\bigr) \;=\; \max(0, 0) \;=\; 0\]Last step:
1
2
3
4
5
6
7
8
9
10
func (w BankWrapper) SetBalance(ctx context.Context, account sdk.AccAddress, amt *big.Int) error {
coin := sdk.Coin{Denom: types.GetEVMCoinDenom(), Amount: sdkmath.NewIntFromBigInt(amt)}
convertedCoin, err := types.ConvertEvmCoinDenomToExtendedDenom(coin)
if err != nil {
return errors.Wrap(err, "failed to set coins in bank wrapper")
}
return w.UncheckedSetBalance(ctx, account, convertedCoin)
}
UncheckedSetBalance is honestly named. It writes the coin into the bank store and never touches the supply tracker. bank total-supply stays exactly where it was. The chain has no idea these tokens exist.
Deriving the number
Running this against a node gives a victim balance of:
\[B \;=\; 115792089237316195423570985008687907853269984665640564039367584004340954280236\]78 digits. My first sanity check used a rough formula, \(2^{256} - D_v + \text{gas}\), and it came out close but not equal, off by \(7060252500000\) wei. That bothered me, so I worked out the closed form properly. There is only one:
\[B \;=\; 2^{256} - (V - \beta)\]which inverts cleanly to recover the EVM visible balance at the instant SubBalance ran:
Now check that \(\beta\) against the schedule. The chain reported \(D_v = 99999996511922859700\), so the vested amount at the moment of the call was:
\[\mathrm{vested} \;=\; V - D_v \;=\; 3488077140300 \ \text{wei}\]and the vesting rate over a ten year schedule is:
\[\frac{V}{t_1 - t_0} \;=\; \frac{10^{20}}{315360000} \;=\; 317097919837 \ \text{wei/s}\]Dividing one by the other gives \(11.0\) seconds elapsed between account creation and the delegate call, which matches the block timing exactly. Carrying that forward:
\[\mathrm{locked} \;=\; \mathrm{vesting} - 0 \;=\; 99999996511922859700\] \[\mathrm{spendable} \;=\; 110 \cdot 10^{18} - \mathrm{locked} \;=\; 10000003488077140300\] \[\mathrm{fee} \;=\; \mathrm{spendable} - \beta \;=\; 7060252500000 \ \text{wei}\]Which is precisely the amount my first formula was off by. The residual was the transaction fee the whole time, and everything reconciles to the wei with nothing left over. It means the wrapped balance is not corruption and not a race. It is a deterministic function of two quantities the attacker chooses in advance: \(V\) when the vesting account is created, and \(\beta\) by deciding how much gas money to leave in the account.
The balance is reported as fully spendable, because \(\mathrm{locked} = 0\), and it moves through ordinary MsgSend transfers like any other native token.
Fixing it
uint256 already ships SubOverflow. Using it kills this whole bug class at the bottom of the stack, whichever caller triggers it:
1
2
3
4
5
6
7
8
9
10
func (s *stateObject) SubBalance(amount *uint256.Int) (uint256.Int, error) {
if amount.IsZero() {
return *(s.Balance()), nil
}
newBalance, underflow := new(uint256.Int).SubOverflow(s.Balance(), amount)
if underflow {
return uint256.Int{}, fmt.Errorf("balance underflow: %s - %s", s.Balance(), amount)
}
return s.SetBalance(newBalance), nil
}
Closing
What I like about this one is that there ALMOST no bad code in it anywhere. GetAccount reporting spendable is the good choice and it prevents a far more obvious bug. DelegateCoins spending locked coins is intended behaviour that exists for good reasons. uint256.Sub wrapping is the specified semantics of the type. Re adding LockedCoins is correct restoration logic. four “correct” decisions, that create an unlimited mint.
Tip: you do not find this by auditing functions, because the functions are fine. You find it by picking two subsystems that both touch the same number, writing down what each one believes that number means, and looking for the case where the two definitions come apart. Here they come apart on ContinuousVestingAccount, and the gap between them is worth \(2^{256}\).
whoami
My name is Daniele Berardinelli. I’m 20 years old, and consider myself a security researcher ever since I realised that vulnerabilities often lie not in individual functions, but in the logic.
The bug has been reported through HackerOne.






