前言
一大早醒来,就看到又有一个人财富自由了,据很多人的消息,Solana 的 WormHole 跨链桥被黑了,被盗了 12 万个 wETH。约3亿美金。事情还是挺严重的,由于我本身不怎么懂 Solana 合约,所以本分析会基于以下两条推特做一个简单的讲解,算是拾人牙慧,也算顺带算是一个记录。
https://twitter.com/samczsun/status/1489044939732406275?s=21 https://twitter.com/kelvinfichter/status/1489048862824226816
话不多说,老样子,我们开始分析
技术分析
长话短说,首先这次的问题并不在 ETH
的桥上,虽然源头是从以太坊那边出来的,但是攻击者在以太坊那边的签名是正确的。所以,是攻击者在 Solana
那边早就 mint
了很多 WormHole ETH
最后导致黑客通过跨链兑换了大量的 wETH
到以太坊上。
那么既然问题在 Solana
上,就要取追踪 Solana
那边的合约发生了什么问题,根据 samczsun
和 kelvinfichter
的说法来看,出现异常的交易(https://solscan.io/tx/2zCz2GgSoSS68eNJENWrYB48dMM1zmH8SZkgYneVDv2G4gRsVfwu5rNXtK5BKFxn7fSqX9BvrBc1rdPAeBEcD6Es),攻击者直接就通过协议本身异常的在 Solana
侧 mint
出了大量的 WormHole ETH
出来
然后通过定位到 WormHole
合约里的一个 complete_wrapped
函数(https://github.com/certusone/wormhole/blob/8d15138d5754b5e1202ff8581012debef25f7640/solana/modules/token_bridge/program/src/instructions.rs#L190),该函数就是用于在 Solana
侧进行铸币操作的。
pub fn complete_wrapped(
program_id: Pubkey,
bridge_id: Pubkey,
payer: Pubkey,
message_key: Pubkey,
vaa: PostVAAData,
payload: PayloadTransfer,
to: Pubkey,
fee_recipient: Option<Pubkey>,
data: CompleteWrappedData,
) -> solitaire::Result<Instruction> {
let config_key = ConfigAccount::<'_, { AccountState::Uninitialized }>::key(None, &program_id);
let (message_acc, claim_acc) = claimable_vaa(program_id, message_key, vaa.clone());
let endpoint = Endpoint::<'_, { AccountState::Initialized }>::key(
&EndpointDerivationData {
emitter_chain: vaa.emitter_chain,
emitter_address: vaa.emitter_address,
},
&program_id,
);
let mint_key = WrappedMint::<'_, { AccountState::Uninitialized }>::key(
&WrappedDerivationData {
token_chain: payload.token_chain,
token_address: payload.token_address,
},
&program_id,
);
let meta_key = WrappedTokenMeta::<'_, { AccountState::Uninitialized }>::key(
&WrappedMetaDerivationData { mint_key },
&program_id,
);
let mint_authority_key = MintSigner::key(None, &program_id);
Ok(Instruction {
program_id,
accounts: vec![
AccountMeta::new(payer, true),
AccountMeta::new_readonly(config_key, false),
message_acc,
claim_acc,
AccountMeta::new_readonly(endpoint, false),
AccountMeta::new(to, false),
if let Some(fee_r) = fee_recipient {
AccountMeta::new(fee_r, false)
} else {
AccountMeta::new(to, false)
},
AccountMeta::new(mint_key, false),
AccountMeta::new_readonly(meta_key, false),
AccountMeta::new_readonly(mint_authority_key, false),
// Dependencies
AccountMeta::new_readonly(solana_program::sysvar::rent::id(), false),
AccountMeta::new_readonly(solana_program::system_program::id(), false),
// Program
AccountMeta::new_readonly(bridge_id, false),
AccountMeta::new_readonly(spl_token::id(), false),
],
data: (crate::instruction::Instruction::CompleteWrapped, data).try_to_vec()?,
})
}
这个函数本身会使用一个参数类似 message_acc
(#39行,由 claimable_vaa
进行检查) 来确定要铸币的类型和对应的数量,这个消息本身是由 WormHole
的 guardians
(类似我们常见桥中的 relayer
) 来进行签名的。但是 Solana
的机制中,这个参数实际上不是一个像以太坊一样的字符串,而是一个合约地址,并且由于 message_acc
是由 vaa
信息生成的,也就是说,攻击者需要生成一个合法的 vaa
数据来传入到 complete_wrapped
合约中,那么关键是这个用于存储铸币的消息合约是怎么创建的呢?
通过继续查看对应的代码,发现生成这个 vaa
信息的代码位于post_vva
函数中(https://github.com/certusone/wormhole/blob/9a4af890e3e2d4729fe70e43aaced39ba8b33e35/solana/bridge/program/src/instructions.rs#L162), 这个函数会接收一个签名集和一个指定的 vaa_data
,如果签名通过的话这个 vaa_data
就算是通过了。
pub fn post_vaa(
program_id: Pubkey,
payer: Pubkey,
signature_set: Pubkey,
vaa: PostVAAData,
) -> Instruction {
let bridge = Bridge::<'_, { AccountState::Uninitialized }>::key(None, &program_id);
let guardian_set = GuardianSet::<'_, { AccountState::Uninitialized }>::key(
&GuardianSetDerivationData {
index: vaa.guardian_set_index,
},
&program_id,
);
let msg_derivation_data = &PostedVAADerivationData {
payload_hash: hash_vaa(&vaa).to_vec(),
};
let message =
PostedVAA::<'_, { AccountState::MaybeInitialized }>::key(&msg_derivation_data, &program_id);
Instruction {
program_id,
accounts: vec![
AccountMeta::new_readonly(guardian_set, false),
AccountMeta::new_readonly(bridge, false),
AccountMeta::new_readonly(signature_set, false),
AccountMeta::new(message, false),
AccountMeta::new(payer, true),
AccountMeta::new_readonly(sysvar::clock::id(), false),
AccountMeta::new_readonly(sysvar::rent::id(), false),
AccountMeta::new_readonly(solana_program::system_program::id(), false),
],
data: (crate::instruction::Instruction::PostVAA, vaa)
.try_to_vec()
.unwrap(),
}
}
但是 post_vaa
本身按照 Solana
的机制来看是不负责进行签名的检查的。signature_set
其实也是一系列的合约地址, 这些签名合约的生成和检查实际是在 verify_signatures
上
pub fn verify_signatures(
program_id: Pubkey,
payer: Pubkey,
guardian_set_index: u32,
signature_set: Pubkey,
data: VerifySignaturesData,
) -> solitaire::Result<Instruction> {
let guardian_set = GuardianSet::<'_, { AccountState::Uninitialized }>::key(
&GuardianSetDerivationData {
index: guardian_set_index,
},
&program_id,
);
Ok(Instruction {
program_id,
accounts: vec![
AccountMeta::new(payer, true),
AccountMeta::new_readonly(guardian_set, false),
AccountMeta::new(signature_set, true),
AccountMeta::new_readonly(sysvar::instructions::id(), false),
AccountMeta::new_readonly(sysvar::rent::id(), false),
AccountMeta::new_readonly(solana_program::system_program::id(), false),
],
data: (crate::instruction::Instruction::VerifySignatures, data).try_to_vec()?,
})
}
在 verify_signatures
函数中,最终会传入一个系统合约来提供对应的接口来进行签名的检查 (#24 行)。
pub fn verify_signatures(
ctx: &ExecutionContext,
accs: &mut VerifySignatures,
data: VerifySignaturesData,
) -> Result<()> {
accs.guardian_set
.verify_derivation(ctx.program_id, &(&*accs).into())?;
let sig_infos: Vec<SigInfo> = data
.signers
.iter()
.enumerate()
.filter_map(|(i, p)| {
if *p == -1 {
return None;
}
return Some(SigInfo {
sig_index: *p as u8,
signer_index: i as u8,
});
})
.collect();
let current_instruction = solana_program::sysvar::instructions::load_current_index(
&accs.instruction_acc.try_borrow_mut_data()?,
);
if current_instruction == 0 {
return Err(InstructionAtWrongIndex.into());
}
// The previous ix must be a secp verification instruction
let secp_ix_index = (current_instruction - 1) as u8;
let secp_ix = solana_program::sysvar::instructions::load_instruction_at(
secp_ix_index as usize,
&accs.instruction_acc.try_borrow_mut_data()?,
)
.map_err(|_| ProgramError::InvalidAccountData)?;
// Check that the instruction is actually for the secp program
if secp_ix.program_id != solana_program::secp256k1_program::id() {
return Err(InvalidSecpInstruction.into());
}
let secp_data_len = secp_ix.data.len();
if secp_data_len < 2 {
return Err(InvalidSecpInstruction.into());
}
但是呢,这个最终实现签名检查的 verify_signatures
函数是调用了上面的 solana_program::sysvar::instructions::load_instruction_at
来加载外部的一个 instructions
(外部合约)进行检查签名的,这个函数其实就是 Solana
系统合约里的一个接口。但是这个 load_instruction_at
接口其实已经是一个废弃的接口了,这个签名检查的接口是没有检查传入的地址是不是一个系统合约地址的。正常来说,在第一个 verify_signatures
#22 行的地方,是要提供一个系统合约的地址来用作签名的检查。但是攻击者传入的并不是一个系统合约地址。也就是说,这个签名检查合约,其实是攻击者自己构建的。
总结
那么到这里,其实就明白得差不多了,实际上是 WormHole
桥用了有问题的 Solana
系统合约,这个系统合约的 load_instruction_at
接口并没有对对应要加载的 instruction
进行检查,导致加载了攻击者自己的构建的 instruction
,导致签名被完全绕过,进而进行无限铸币。
如果硬是要类比的话,就类似以太坊合约中使用 Interface
接口的时候没有检查对应实例化的地址是不是自己的项目合约地址,导致某些检查逻辑被绕过,是类似的。
目前来看的话,只要升级到最新的系统库,就是没问题了。本文可能有诸多错误,在于我本身其实没有对 Solana
有充分的了解,但是结论部分是没问题的,最根本的原因是 WormHole
使用了过期的系统合约导致了问题的发生。
原文始发于微信公众号(蛋蛋的区块链笔记):通向自由的 “黑洞” — WormHole 被黑分析