DEV Community

Cover image for A signature confirms bytes, not intent
AutoMate AI
AutoMate AI

Posted on

A signature confirms bytes, not intent

I wrote a four-line proof of concept that drained a bridge vault using a signature the operator had issued honestly, once, for a legitimate withdrawal of 100 tokens.

Nothing was forged. The signature was real, the signer was authorised, the maths checked out every time. It just ran ten times instead of one.

That is the whole lesson, and it is not really about blockchains:

A signature confirms bytes. It says nothing about intent, and nothing about how many times those bytes may be used.

What the signer actually sees

Ask someone to sign 0x000...f0c36e... and they will. To them it is noise. The contract on the other side decodes those same bytes into "call address X with data Y", and between return the user's 100 tokens and hand over the vault forever, the signer sees no difference at all. Both look like the same shrug of hex.

So after every ecrecover I now ask exactly two questions:

  1. What must not be replayable?
  2. How wide is the meaning of what was signed?

Most signature bugs I have found are one of those two, wearing different clothes.

Question one: replay

Here is the message that cost that vault everything:

keccak256(abi.encode(target, value, data))
Enter fullscreen mode Exit fullscreen mode

Read what is not in there. No nonce. No deadline. No chain id. No address of the contract meant to consume it. And the contract kept no record of digests it had already honoured.

One honest signature, replayed in a loop, until the vault was empty.

The minimum a signed message needs, and what each field actually buys you:

Field What it stops
nonce (or a mapping of spent digests) the same message used twice
deadline a signature from six months ago waking up
chain id the message being replayed on another chain
address(this) replay against a twin deployment
the address allowed to submit it a bystander front-running the execution

This is exactly what EIP-712's domain separator gives you. It is not ceremony. Every field closes one specific attack, and if you hand-roll the digest you will drop one — usually chain id, because there is only one chain the day you write it.

Question two: how wide is the meaning?

The second bug was worse, and it did not need any replay.

target.call{value: value}(data);   // target and data come from the signed message
Enter fullscreen mode Exit fullscreen mode

The bridge was the owner of the vault. So a signature over (vault, 0, approveTo(attacker, max)) gave the attacker an unlimited, permanent allowance on everything inside. In my PoC I then paused the bridge — and still emptied the vault, because the approval lives in the token contract and pausing the bridge does not touch it.

This is the confused deputy, and it is the shape to watch for: a privileged component that lends its privileges to whoever brings signed bytes. The bridge had more authority than any signer, and it handed that authority over on request.

The fix is not "the signer will be careful". Humans and backends are not a security boundary. The contract must restrict what it will execute — an allowlist of targets and function selectors — so that even a signature over something catastrophic decodes into something it refuses to do.

The design target I would write down for any such system:

With the signing key fully compromised, an attacker can grief liveness and fairness, but cannot break solvency.

That is a property you can test. "Our backend is trusted" is not.

The web2 version of the same bug

None of this is exotic to smart contracts. Change the words and it is your Friday afternoon:

  • Stripe and GitHub webhooks. The HMAC proves the payload was produced by someone holding the secret. It does not prove you have not already processed this exact event. Retries are normal, at-least-once delivery is normal. If your handler is not idempotent on the event id, a duplicate payment_succeeded ships the goods twice. That is a replay, in a hoodie.
  • JWTs without aud and exp. A token minted for your staging service, accepted by production, is the missing-address(this) bug exactly.
  • Signed URLs with no expiry. Someone pastes it in a ticket, and it works forever.
  • "Signed" internal RPC where the payload names the method to call. Congratulations, you built the confused deputy in Python.

Two smaller traps worth knowing

ecrecover returns address(0) for a bad signature — it does not revert. If signers[address(0)] is ever true, or your check compares against an uninitialised variable, a signature can be forged with emptiness. OpenZeppelin's ECDSA.recover reverts instead, and also rejects the malleable upper half of s. Use the wrapper.

Precompiles are not library code. ecrecover is a staticcall to address 0x01, implemented by the chain client. On a chain where that precompile is absent, the staticcall to an empty address succeeds with empty return data — so verification "passes" with a zero result. If you deploy to more than one chain, check the returned data size, and treat multi-chain deployment as its own vulnerability class rather than a release detail.

What I do now

Before I trust any signature check, I write the answers down:

  1. What exactly is inside the signed bytes? (If nonce, deadline, chain id or contract address are missing, stop here.)
  2. What is the widest possible action those bytes can decode into?
  3. If the signing key leaked tonight, what is the worst outcome — and is it inconvenience, or insolvency?

The third question is the one that changes designs. Most systems, honestly answered, discover their backend key is load-bearing for something it should never have been trusted with.

The signature was never the promise. It was only ever the bytes.

Top comments (0)