0xtruthlover
← Writing

Disclosure4 min read

40,400 signature checks for a Stellar transaction nobody paid for

An invalid, fee-free Stellar transaction could make every validator that saw it run 40,400 Ed25519 checks on one shared thread.

Severity
High
Program
Stellar
Platform
Immunefi
Status
Paid

An invalid Stellar transaction costs nothing to send, yet it could make every validator that received it run 40,400 full Ed25519 signature checks, the expensive cryptographic kind. A normal transaction needs one to four.

Those checks all ran on a single thread, the one that handles every message a node gets from its peers. While it was busy, nothing else got through.

The attacker pays once: about 1,050 XLM, roughly $137, to set up some accounts. Every attack transaction after that is free.

The Stellar team confirmed my Immunefi report the next day and paid it as High.

How the shortcut works

stellar-core is the software Stellar validators run. Before it lets a transaction into its mempool, the waiting room for unconfirmed transactions, it checks the signatures.

A full Ed25519 check is expensive, so each signature carries a 4-byte hint, a cheap label saying which signer it probably belongs to. The node compares hints first and runs the real check only when one matches.

No match, skip. Match, run the full check, and if it passes, cross that signer off the list.

Since December 2025, nodes also do this by default in the background, as transactions arrive. That work runs on the overlay thread, the one handling all peer traffic.

Forcing every hint to match

Stellar supports a signer type called ED25519_SIGNED_PAYLOAD: a key that signs off on one specific piece of data the account owner chose. Its hint mixes the key with a payload, and the account owner picks that payload.

So I could pick payloads that gave every signer the same hint. No brute force, just arithmetic.

That turns the filter into a trap. Every signature now matches every signer, and every match costs a full check. Three more details turned that into a multiplier:

  • The loop is signatures times signers. With 20 signers and 20 random, invalid signatures, nothing ever verifies, so nobody gets crossed off. That is 400 full checks.
  • Operations do not stop early. The per-operation check keeps going after a failure, and every operation runs another 400 checks: the same 20 signatures against its own account’s 20 signers.
  • The background path ignores the answer. The normal validation path bails out as soon as the transaction-level check fails. The background path throws the result away and checks every operation anyway.

In simplified form:

// background verification on the overlay thread
checkAllTransactionSignatures(tx)   // result ignored
checkOperationSignatures(tx)        // runs regardless

// inside each check
for sig in signatures:              // 20
  for signer in signers:            // 20
    if hint(sig) == hint(signer):   // forced to always match
      fullEd25519Verify(sig, signer)

Building the transaction

  1. Create a set of ordinary accounts and give each the maximum 20 signed-payload signers, all tuned to share one hint. This is the $137 part, and it only happens once.
  2. Build a transaction whose source and up to 100 operations each use a different one of those accounts.
  3. Attach 20 signatures that carry the shared hint but are just random bytes.
  4. Send it to peers. Each receiving node runs 400 checks for the transaction and 400 more for every operation: (1 + 100) x 400 = 40,400.
  5. Repeat with fresh random signatures. The node’s cache of past signature checks never helps, because every signature is new. And invalid transactions are never charged a fee.

My proof of concept ran this on a live in-memory node with 10 operations instead of 100, to keep it fast. It counted exactly 4,400 real verifications and took about 79 ms. Each operation adds the same independent work, so 100 operations comes out to roughly 712 ms per transaction.

What one connection can do

The overlay thread does one thing at a time. While it grinds through a malicious transaction, it is not handling consensus messages, transaction adverts and requests, or peer heartbeats.

Flow control, the per-peer cap on how many messages a node will read, lets one connection push 200 transactions in its first burst. At about 700 ms each, that ties up the overlay thread for around 140 seconds. From one connection.

More connections multiply it. Kept up, it degrades peer-to-peer traffic across the network and can get nodes dropped by their peers for timing out.

No special access needed beyond a funded account. Any peer connection works, and the feature is on by default.

Consensus runs on a different thread, so this does not directly halt the network. That is why I rated it High, not Critical.

The fix

I recommended one main change and a few backstops:

  • Respect the answer. In background verification, skip the per-operation checks when the transaction-level check fails, the same way the normal path already does.
  • Stop at the first failure when checking operation signatures.
  • Bucket signers by hint, so each signature is only tested against signers whose hint actually matches, instead of looping over all of them.
  • Budget work, not messages. Give each peer a limit on how much signature-checking it can cause, not just how many messages it can send.

The team confirmed the report. I do not know yet which of these they shipped, or when.

Takeaways

  • A shortcut is only safe if the attacker cannot choose its input. A 4-byte hint the user controls is not a filter. It is an invitation.
  • When you copy a check into a second code path, copy the early exit too. The background path did the same work as the main one, minus the part that said stop.
  • Count cost in CPU, not in messages. One message can be cheap to send and very expensive to read.