The Avici exploit: Ed25519 instructions are pointers, not inline data

Recently, Solana card provider Avici was hacked, resulting in approximately $500K in losses. The attack appears to have targeted the rain.xyz program used by Avici, rather than Avici’s own program.

The root cause appears to be a flaw in how Rain’s program verified an admin signature using Solana’s native Ed25519 verification program.

In this blog, we’ll dissect what happened at the transaction level and understand how the attack could have been prevented.

Background

Ed25519 Verification on Solana

Solana provides a native Ed25519 signature verification program:

Ed25519SigVerify111111111111111111111111111

It can be used by programs to verify Ed25519 signatures without implementing the cryptography themselves.

The basic operation we’re interested in is:

verify(publicKey, signature, message)

The important detail, however, is that the Ed25519 instruction does not necessarily contain the values it wants to verify directly.

Instead, the instruction contains offsets and instruction indices that tell the Ed25519 program where to find the public key, signature, and message.

The structure used for a signature entry is:

struct Ed25519SignatureOffsets {
    signature_offset: u16,
    signature_instruction_index: u16,

    public_key_offset: u16,
    public_key_instruction_index: u16,

    message_data_offset: u16,
    message_data_size: u16,
    message_instruction_index: u16,
}

The important fields are the three *_instruction_index values.

They determine which transaction instruction contains the corresponding data.

For example:

public_key_instruction_index = 0

means:

Read the public key from instruction #0.

Similarly:

signature_instruction_index = 0

means:

Read the signature from instruction #0.

And:

message_instruction_index = 0

means:

Read the message from instruction #0.

This means an Ed25519 instruction can effectively contain pointers into other instructions in the same transaction.

So conceptually, an Ed25519 instruction looks more like:

verify(
    data[instruction_index][offset],
    data[instruction_index][offset],
    data[instruction_index][offset]
)

rather than simply:

verify(publicKey, signature, message)

This distinction is extremely important for understanding the exploit.

Attack Dissection

Now that we understand how the Ed25519 instruction works, let’s look at the transaction used by the attacker:

Exploit transaction on Solscan

The transaction contains:

#0  Ed25519 verification instruction
#1  Ed25519 verification instruction
#2  Rain::WithdrawCollateralAsset

The interesting part is that the first 16 bytes of both Ed25519 instructions are identical.

These 16 bytes contain the offsets that tell the Ed25519 program where to find the signature, public key, and message.

The data begins with:

01 00
30 00 00 00
10 00 00 00
70 00 20 00

Decoding these values as little-endian integers gives:

Field#0#1
Number of signatures11
Signature offset0x0030 = 4848
Signature instruction0x0000 = 00
Public key offset0x0010 = 1616
Pubkey instruction0x0000 = 00
Message offset0x0070 = 112112
Message size0x0020 = 3232
Message instruction0x0000 = 00

The important part is this:

signature_instruction_index = 0
public_key_instruction_index = 0
message_instruction_index = 0

for both instructions.

In other words, both Ed25519 instructions are telling the Ed25519 program:

Get the signature, public key and message from instruction #0.

Instruction #0

The data for instruction #0 is:

01 00
30 00 00 00
10 00 00 00
70 00 20 00

a86dd119abc2a9ff053b55ce7bf1f92a616d8a18337c4aa9d1da9722251e84a3

39d4d1fa4b4e1c0758c4571bd487d4d58c7cef7ac3aa33fc32b2c81d6c5d077
f4e4d20cc9a6f089f5f76587fe0ddacf710029899d23783cf9cd67ee577c1bd0e

b323f8abf60c57c3153b32139b19a390da2c38db01932f670af29bc03d134140

The offsets tell us where each piece of data is located.

Public key

The public key starts at offset 16:

a86dd119abc2a9ff053b55ce7bf1f92a616d8a18337c4aa9d1da9722251e84a3

This corresponds to the attacker’s Solana public key:

CLUciQXJeHr5LD2UiojFpUqFNJq9yNshcTKUSeahcWFC

Signature

The signature starts at offset 48 (0x30) and is 64 bytes:

39d4d1fa4b4e1c0758c4571bd487d4d58c7cef7ac3aa33fc32b2c81d6c5d077
f4e4d20cc9a6f089f5f76587fe0ddacf710029899d23783cf9cd67ee577c1bd0e

Message

The message starts at offset 112 (0x70) and is 32 bytes:

b323f8abf60c57c3153b32139b19a390da2c38db01932f670af29bc03d134140

Therefore, instruction #0 effectively asks the Ed25519 program to verify:

verify(
    public_key = CLUciQXJeHr5LD2UiojFpUqFNJq9yNshcTKUSeahcWFC,
    signature  = 39d4d1fa...c1bd0e,
    message    = b323f8ab...d134140
)

The attacker controls the corresponding private key, so this is a perfectly valid signature.

The Ed25519 program therefore returns success.

Instruction #1

This is where things get interesting.

Instruction #1 contains:

01 00
30 00 00 00
10 00 00 00
70 00 20 00

834e7d2b8024f0980a869132c73a36a290343551103ff932de4b7d33b1ef724f

0909090909090909090909090909090909090909090909090909090909090909
0909090909090909090909090909090909090909090909090909090909090909

b323f8abf60c57c3153b32139b19a390da2c38db01932f670af29bc03d134140

If we simply look at the bytes inside instruction #1, we see:

Public key

834e7d2b8024f0980a869132c73a36a290343551103ff932de4b7d33b1ef724f

This corresponds to the privileged Rain key:

9qZppNiUihHJa6GUzyD7Zqgic5mLDcno6rvKMQZDGExn

Signature

The next 64 bytes are:

0909090909090909090909090909090909090909090909090909090909090909
0909090909090909090909090909090909090909090909090909090909090909

This is clearly not a valid Ed25519 signature.

Message

The message is:

b323f8abf60c57c3153b32139b19a390da2c38db01932f670af29bc03d134140

At first glance, this looks like the attacker has constructed an Ed25519 instruction containing:

privileged_pubkey
fake_signature
message

But that is not what the Ed25519 program actually verifies.

Remember the offsets:

signature_instruction_index = 0
public_key_instruction_index = 0
message_instruction_index = 0

All three point to instruction #0.

Therefore, when the Ed25519 program processes instruction #1, it resolves the references as:

signature

instruction #0

public key

instruction #0

message

instruction #0

So despite instruction #1 containing the privileged public key, the Ed25519 program actually verifies:

verify(
    public_key = CLUciQXJeHr5LD2UiojFpUqFNJq9yNshcTKUSeahcWFC,
    signature  = 39d4d1fa...c1bd0e,
    message    = b323f8ab...d134140
)

It does not verify:

verify(
    public_key = 9qZppNiUihHJa6GUzyD7Zqgic5mLDcno6rvKMQZDGExn,
    signature  = 09090909...,
    message    = b323f8ab...4140
)

The latter is simply the data embedded inside instruction #1.

Where the Vulnerability Comes In

At this point, Solana’s Ed25519 verification is working exactly as intended.

The problem occurs when Rain’s WithdrawCollateralAsset instruction inspects the previous instructions using the Instructions Sysvar.

The intended logic was presumably something similar to:

1. Find an Ed25519 instruction.
2. Read the public key from it.
3. Check that the public key is the privileged Rain key.
4. Assume that this key was verified by the Ed25519 program.
5. Allow the withdrawal.

The problem is step 4.

The public key that appears inside instruction #1 is the privileged key:

9qZppNiUihHJa6GUzyD7Zqgic5mLDcno6rvKMQZDGExn

But the Ed25519 instruction is actually telling the verifier:

public_key_instruction_index = 0

Therefore, the public key actually verified by Solana was:

CLUciQXJeHr5LD2UiojFpUqFNJq9yNshcTKUSeahcWFC

the attacker’s key.

We therefore have a mismatch:

                What Rain sees

Instruction #1

     └── public key = PRIVILEGED KEY


                  "Looks good"


                What Ed25519 verifies

Instruction #1

     └── public_key_instruction_index = 0


                Instruction #0


                 ATTACKER KEY


                    VALID

The attacker effectively made the program believe:

PRIVILEGED_KEY signed MESSAGE

while the Ed25519 verifier actually established:

ATTACKER_KEY signed MESSAGE

This is the core of the vulnerability.

The cryptography itself was never broken.

Ed25519 correctly verified a valid signature from the attacker’s key.

The bug was in how the Rain program interpreted the Ed25519 instruction and associated the verified signature with the privileged key.

Why Two Ed25519 Instructions?

This also explains why the attacker used two Ed25519 instructions.

The first instruction contains the real, valid signature:

Instruction #0

ATTACKER_KEY
VALID_SIGNATURE
MESSAGE

The second instruction contains the values that Rain apparently expected to see:

Instruction #1

PRIVILEGED_KEY
FAKE_SIGNATURE
MESSAGE

while its offsets point back to instruction #0:

Instruction #1

      ├── public key ────────┐
      ├── signature ─────────┤
      └── message ───────────┤


                        Instruction #0

This creates two different interpretations of the same transaction.

The Ed25519 verifier follows the references and sees:

ATTACKER_KEY + VALID_SIGNATURE + MESSAGE

while the vulnerable Rain parser apparently looked at the inline data and saw:

PRIVILEGED_KEY + FAKE_SIGNATURE + MESSAGE

That mismatch is what makes the attack possible.

Why Not Simply Make the Admin a Signer?

This raises an obvious question.

If Rain wants an admin to authorize a withdrawal, why not simply include the admin’s public key as an account in the transaction and check:

require!(admin.is_signer);

That would indeed be much simpler if the desired authorization model is that the admin signs the transaction itself.

For example:

Transaction

├── Admin signature

└── Rain::WithdrawCollateralAsset

        └── admin.is_signer == true

In this design, Solana itself guarantees that the admin’s key signed the transaction.

However, there is an important distinction between a transaction signature and a signed authorization/message.

Transaction Signature vs Signed Authorization

Suppose Rain wants an admin to approve withdrawals, but does not want the admin to sign every transaction.

The admin could instead sign a message off-chain:

RAIN_WITHDRAW
user = Alice
asset = USDC
amount = 100
nonce = 123
expiry = ...

Conceptually:

Admin private key


sign(withdrawal_authorization)


Signed authorization

The signed authorization can then be submitted by someone else:

User / Relayer


Solana transaction

       ├── Ed25519 verification

       └── Rain::Withdraw

The admin did not sign the Solana transaction.

They only signed the specific authorization.

This is useful for systems such as:

  • gasless transactions
  • relayers
  • delegated withdrawals
  • backend-controlled approvals
  • permits
  • off-chain order signing
  • meta-transactions

For example, the admin might sign:

RAIN_WITHDRAW_V1

user:    Alice
asset:   USDC
amount:  100
nonce:   123
expiry:  1788000000

and the Rain program can verify that exact authorization.

This is fundamentally different from:

admin.is_signer == true

because the admin is authorizing a specific message, rather than signing the entire transaction.

How Could This Have Been Prevented?

The fundamental rule when parsing Ed25519 instructions is:

Never assume that the bytes embedded in an Ed25519 instruction are the bytes that were actually verified.

The program must resolve the offsets and instruction indices.

For example, if Rain expects:

authorized_pubkey = 9qZppNiUihHJa6GUzyD7Zqgic5mLDcno6rvKMQZDGExn

it needs to establish that the Ed25519 instruction actually references that key:

Ed25519 instruction

        ├── public_key_instruction_index
        ├── public_key_offset


actual public key


must equal authorized_pubkey

The same applies to the signature and message.

A secure implementation should verify all of the following:

1. The instruction is actually the Ed25519 verification program.

2. The Ed25519 instruction has the expected structure.

3. The public key reference resolves to the expected
   privileged public key.

4. The signature reference resolves to the expected signature.

5. The message reference resolves to exactly the message
   the Rain instruction expects.

6. The message is bound to the intended operation.

7. Replay protection is implemented, e.g. with a nonce.

8. The authorization cannot be reused for another user,
   asset, amount, or operation.

The most important part is that Rain must reason about the same data that the Ed25519 verifier reasons about.

Takeaway

This attack is a good example of why cryptographic verification alone is not enough.

The Ed25519 primitive was working correctly.

The attacker had a legitimate key:

ATTACKER_PRIVATE_KEY


sign(MESSAGE)


VALID_SIGNATURE

Solana correctly verified that signature.

The vulnerability was in the interpretation of the verification instruction:

                 Same transaction

        ┌─────────────────────────────┐
        │ Ed25519 verification        │
        │                             │
        │ follows instruction indexes │
        │           │                 │
        │           ▼                 │
        │      ATTACKER_KEY           │
        │           │                 │
        │           ▼                 │
        │         VALID               │
        └─────────────────────────────┘


        ┌─────────────────────────────┐
        │ Rain program                │
        │                             │
        │ reads inline public key     │
        │           │                 │
        │           ▼                 │
        │      PRIVILEGED_KEY         │
        │           │                 │
        │           ▼                 │
        │       "Authorized"          │
        └─────────────────────────────┘

The lesson for Solana developers is therefore:

When using the Instructions Sysvar to inspect Ed25519 verification instructions, treat the instruction as a set of references, not as a simple {publicKey, signature, message} structure. Always resolve and validate the referenced data before using the verification result for authorization.

A signature verification bug can therefore exist even when Ed25519 itself is perfectly secure.