通向自由的 “黑洞” — WormHole 被黑分析

区块链安全 2年前 (2022) admin
510 0 0


前言

一大早醒来,就看到又有一个人财富自由了,据很多人的消息,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 到以太坊上。

通向自由的 “黑洞” — WormHole 被黑分析

那么既然问题在 Solana 上,就要取追踪  Solana 那边的合约发生了什么问题,根据 samczsun kelvinfichter 的说法来看,出现异常的交易(https://solscan.io/tx/2zCz2GgSoSS68eNJENWrYB48dMM1zmH8SZkgYneVDv2G4gRsVfwu5rNXtK5BKFxn7fSqX9BvrBc1rdPAeBEcD6Es),攻击者直接就通过协议本身异常的在 Solana  mint 出了大量的 WormHole ETH 出来 


通向自由的 “黑洞” — WormHole 被黑分析

然后通过定位到 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 被黑分析

总结

那么到这里,其实就明白得差不多了,实际上是 WormHole 桥用了有问题的 Solana 系统合约,这个系统合约的 load_instruction_at 接口并没有对对应要加载的 instruction 进行检查,导致加载了攻击者自己的构建的 instruction,导致签名被完全绕过,进而进行无限铸币。

如果硬是要类比的话,就类似以太坊合约中使用 Interface 接口的时候没有检查对应实例化的地址是不是自己的项目合约地址,导致某些检查逻辑被绕过,是类似的。

目前来看的话,只要升级到最新的系统库,就是没问题了。本文可能有诸多错误,在于我本身其实没有对 Solana 有充分的了解,但是结论部分是没问题的,最根本的原因是 WormHole 使用了过期的系统合约导致了问题的发生。

原文始发于微信公众号(蛋蛋的区块链笔记):通向自由的 “黑洞” — WormHole 被黑分析

版权声明:admin 发表于 2022年2月3日 上午6:52。
转载请注明:通向自由的 “黑洞” — WormHole 被黑分析 | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...