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 signatures | 1 | 1 |
| Signature offset | 0x0030 = 48 | 48 |
| Signature instruction | 0x0000 = 0 | 0 |
| Public key offset | 0x0010 = 16 | 16 |
| Pubkey instruction | 0x0000 = 0 | 0 |
| Message offset | 0x0070 = 112 | 112 |
| Message size | 0x0020 = 32 | 32 |
| Message instruction | 0x0000 = 0 | 0 |
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.