
Clients use an IDL to build and explain transactions, so trusting an IDL means trusting part of the signing flow. A malicious IDL can make a real Solana program look like it exposes safe instructions while the client sends something completely different, such as calling withdrawFunds when the user selects depositFunds. The program ID and deployed code can both be correct. The lie sits in the interface.
For example, say a program has this instruction:
{
"name": "withdrawFunds",
"discriminator": [241, 36, 29, 111, 208, 31, 104, 217],
"accounts": [
{ "name": "withdrawAuthority", "signer": true },
{ "name": "withdrawTo", "writable": true },
{ "name": "vault", "writable": true }
],
"args": [
{ "name": "amount", "type": "u64" }
]
}
The discriminator is the first eight bytes of sha256("global:withdraw_funds").
Now somebody publishes this IDL entry for the same program:
{
"name": "depositFunds",
"discriminator": [241, 36, 29, 111, 208, 31, 104, 217],
"accounts": [
{ "name": "depositAuthority", "signer": true },
{
"name": "protocolTreasury",
"writable": true,
"address": "CktRuQ2mttgRGkXJtyksdKHjUdc2C4TgDzyB98oEzy8"
},
{ "name": "vault", "writable": true }
],
"args": [
{ "name": "amount", "type": "u64" }
]
}
Same discriminator and argument layout. Account order matches. But completely different names.
On-chain, those names do not exist. A Solana instruction contains a program ID, an ordered list of account metas, and some data bytes. The account metas contain public keys plus signer and writable permissions. The target program receives those fields and interprets them. This is the instruction format documented by Solana.
Anchor puts the discriminator in the IDL and uses it when encoding the instruction. The IDL documentation explains how the discriminator selects the instruction, and the v1.1.2 TypeScript coder prepends the discriminator from the IDL to the serialized arguments.
So this IDL's depositFunds(1_000_000) produces the discriminator for withdraw_funds, followed by the same serialized u64. Account position zero contains the user's signer key, which the program reads as withdraw_authority. Position one contains the attacker, which the program reads as withdraw_to. Position two contains the real vault.
The address field makes this even better for the attacker. Current Anchor IDLs can specify fixed account addresses, signer and writable flags, PDA seeds, and account relations. They are normal fields in the IDL type. During account resolution, Anchor turns the IDL's address value into the account public key.
The malicious client can therefore do this:
await program.methods
.depositFunds(new BN(1_000_000))
.accounts({
depositAuthority: user,
vault,
})
.instruction();
The caller never provides protocolTreasury. It quietly resolves to the attacker's address.
The behavior is confirmed with @anchor-lang/core 1.1.2 and @solana/web3.js 1.98.4. The legitimate and malicious IDLs produce the same program ID, byte-for-byte identical instruction data, and the same ordered account metas. The fixed address resolves to the attacker. The legitimate IDL decodes the bytes as withdrawFunds, while the malicious IDL decodes them as depositFunds. Change one byte in the discriminator and the instruction data changes too.
That covers the client-side construction. Whether it becomes exploitable still depends on the real program accepting those accounts and the victim having the required authority.
There are some hard limits here. A fake IDL cannot remove a signature check, bypass an owner check, or make invalid account data pass deserialization. Most random IDL edits will just produce a failed transaction.
The withdrawal example only works if the real instruction lets the authorized user choose withdraw_to. A program that requires a stored beneficiary or an associated token account owned by the user will reject the attacker's address.
The IDL is useful to the attacker because it packages the lie. It can generate a typed client, resolve the attacker's accounts, and decode the resulting transaction using the same fake names. A malicious frontend could build the raw transaction without an IDL, of course. The IDL makes the whole thing much easier to reuse and distribute.
Immediate theft may also be the easiest version for a good transaction simulator to catch. Authority changes are more interesting.
Authority transfer disguised as account recovery
Suppose the real instruction is:
setWithdrawAuthority(current_authority, new_authority, vault)
The malicious IDL calls it:
enableRecovery(owner, recovery_provider, vault)
recovery_provider resolves to the attacker. The transaction moves no tokens, so a simple balance preview looks harmless. Once it succeeds, the attacker controls future withdrawals.
Delegate approval disguised as staking
A program can approve a delegate over the user's token account. The fake IDL calls the instruction stakeFunds, labels the delegate as a staking authority, and resolves it to the attacker's key.
Nothing has to move in the first transaction. The attacker uses the delegated authority later.
An admin takeover disguised as a multisig safety change
This gets nastier inside a multisig, where several signers may all be looking at the same decoded proposal. The real instruction could be:
setAdmin(multisig, new_admin, protocol_state)
The malicious IDL presents the same instruction as:
activateStricterRiskConfig(multisig, risk_config, protocol_state)
The attacker's key is shown as risk_config. No funds move when the proposal executes, and every signer sees the same reassuring description. The damage comes later, once the new admin uses their authority.
A malicious multisig member cannot normally replace a canonical IDL alone if that update is protected by the same threshold. They need another route into the review path: control of a separate IDL authority, a third-party IDL the interface accepts, a compromised frontend or registry, or an IDL update the multisig approved earlier.
Once the fake IDL is there, requiring more signatures does not solve the problem. The signers are separately approving the same malicious bytes through the same poisoned decoder.
Poisoning everything downstream
Explorers, indexers, alerting systems, investigation dashboards, and tax or accounting software often work from decoded transactions. If they load the same fake IDL, the lie survives long after the transaction is confirmed.
An explorer can show claimRewards instead of withdrawFunds. A sleuth can miss an admin change because the new authority is labeled risk_config. An alert can fail to fire because it keys on decoded instruction names. Tax software that imports an IDL or consumes a poisoned decoded feed can classify a withdrawal as a deposit, trade, or harmless configuration update.
None of this changes execution. It corrupts the record that people and software use to explain the execution.
The previous article, Hidden IDL Instructions and How To Abuse Them, covered an older Anchor problem. Programs could expose permissionless IDL initialization, which allowed somebody to claim the main IDL account before the developer.
Anchor v1 removed those legacy instructions and moved IDL storage to the Program Metadata Program. The v1 release notes describe the change.
Program Metadata distinguishes between canonical and third-party data. A canonical metadata account is derived from the program key and seed and must be created by the program's upgrade authority. Non-canonical metadata includes the publisher's authority key in a separate derivation. The Program Metadata repository documents both account types.
That deals with the old permissionless takeover for programs using the new system. It does not help when a client accepts an IDL from a random package, API, repository, compromised frontend, or non-canonical publisher.
Canonical metadata identifies who published the IDL. You still have to decide whether that publisher is trusted and whether the IDL matches the deployed program. A compromised or malicious upgrade authority can publish a bad canonical IDL too.
Verifiable builds answer a useful question: does this source produce the bytecode deployed at this program address? IDLs need a similar check: does the verified source generate the exact interface this client is loading?
Anchor already has most of the pieces. anchor build generates an IDL from the program source, and anchor verify checks the deployed bytecode and the on-chain IDL when one exists.
The result needs to become something wallets, explorers, SDKs, and multisigs can consume. A useful verification record would bind:
A client should show a "verified IDL" only when the file it loaded matches that record. If the program is upgraded, the verification becomes stale until the new binary and IDL are verified together.
This is stronger than canonical metadata. Canonical metadata tells you that the upgrade authority published the IDL. A verified IDL would tell you that the IDL was generated from the same source that reproduced the deployed program. A compromised authority could still publish malicious canonical JSON for an unchanged program, but it would no longer match the verified IDL hash.
Verification would not prove that the program is safe. It would make a substituted IDL for an unchanged program detectable, which is exactly the missing property in these attacks.
Protocol teams should publish the verified IDL through canonical metadata and ship the same file in their official SDK. Pin the program ID and IDL hash, then update both through the same governed process used for program upgrades.
Wallets, explorers, and client tooling should at least check:
That last point matters. If the same malicious IDL builds the transaction and then explains it to the user, both screens will confidently repeat the same lie.
Multisig interfaces should pin the expected IDL hash for each program and make any change obvious to signers. An IDL update should be reviewed like a program upgrade, not bundled into an unrelated proposal. For sensitive calls, the proposal view should also expose the raw program ID, discriminator, account keys, and state changes instead of relying on one decoded name.
For high-risk instructions, wallets should show the raw program ID, discriminator, signer accounts, writable accounts, and relevant state changes. Balance deltas help with immediate theft. They will not catch a quiet authority or delegate change unless the wallet understands the instruction.
Programs can also reduce the damage by checking dangerous destinations on-chain. Stored beneficiaries, destination-owner checks, allowlists, delays, and two-step authority changes all make malicious clients less useful. The right constraint depends on the protocol, but critical accounts should not be free-form unless they actually need to be.
A program ID by itself does not authenticate the interface used to produce the transaction. If a client accepts arbitrary JSON for a trusted program, the most important part of the signing flow is still unverified.
If your protocol, wallet, multisig, or explorer relies on IDLs to build or explain transactions, talk to us about security consulting or opsec reviews.