Aviso Legal: isso não é uma invasão nas arquiteturas subjacentes da Starknet ou Cairo. Em vez disso, é uma vulnerabilidade muito comum introduzida pelo desenvolvedor que vimos (e divulgamos) por aí. Se você é um desenvolvedor da Cairo - leia e entenda este problema para evitar possíveis invasões em seus dapps.
Todos nós ao redor de cripto conhecemos e amamos a Starknet da Starkware… O próprio Vitalik parece ser um fã:
E não é à toa. A equipe Starkware é absolutamente divina quando se trata de matemática lunar (MoonMath) de escalabilidade ZK (Zero Knowledge). É a principal razão pela qual sua empresa recentemente se tornou uma gigante de $ 8 bilhões e é a principal tecnologia ZK-L2 em TVL (dYdX e Immutable X rodam usando a tecnologia da Starkware).
Entretanto… quando se trata da Cairo, a linguagem usada para escrever contratos da Starknet… a equipe da Starkware teve um grande fracasso. E eu quero dizer ENORME.
Hoje, crianças, vamos aprender como praticamente qualquer contrato da Cairo pode ser invadido (potencialmente).
O diabo nos detalhes — A estrutura Uint256
O Solidity tem uma série de tipos nativos que os desenvolvedores podem usar:
- bool
- uint8 até uint256
- address
- bytes1 até bytes32
- mapping
E alguns outros…
Cairo, por outro lado, tem exatamente um tipo - felt .
O felt é um tipo de 252 bits que pode ser usado para qualquer coisa — ints assinados/não assinados, booleanos, endereços e até mesmo para o bytecode da CairoVM (Máquina Virtual da Cairo).
Por que 252 bits? A razão para isso tem a ver com a matemática lunar do STARK, da Starkware… você pode ler mais sobre isso aqui.
Então se você quer…
- representar um bool - você usa um felt
- representar um uint8 — você usa um felt
- representar um endereço - você usa um felt
- você entendeu…
Mas e se você quiser representar uma uint256? Um felt tem apenas 252 bits…
Para isso você precisaria da estrutura Uint256, definida como tal (fonte):
// Representa um número inteiro na faixa [0, 2^256).
struct Uint256 {
// Os 128 bits de valor baixo.
low: felt,
// Os 128 bits de valor alto.
high: felt,
}
Uma estrutura Uint256 é composta de dois felts — um para representar os 128 bits baixos e outro para representar os 128 bits altos.
Estrutura Uint256 da Cairo
Então, basicamente, toda Uint256 tem 2*124 = 248 bits que são completamente ignorados e nunca são usados.
Mas eles são completamente ignorados? ….
"A comparação é o ladrão da alegria"
Como você já deve ter adivinhado… esses bits inúteis não são ignorados.
Vejamos como as comparações Uint256 funcionam na Cairo.
Em vez de usar <, > e == como uma linguagem normal faria para seus tipos int mais usados, Cairo usa essas funções:
- uint256_lt() para <
- uint256_gt() para >
- uint256_eq() para ==
Agora vamos nos aprofundar na função uint256_lt() para ver se ela realmente ignora ou não esses bits indesejados (fonte):
// Retorna 1 se o primeiro inteiro sem assinatura for menor que o segundo inteiro sem assinatura.
func uint256_lt{range_check_ptr}(a: Uint256, b: Uint256) -> (res: felt) {
if (a.high == b.high) {
return (is_le(a.low + 1, b.low),);
}
return (is_le(a.high + 1, b.high),);
}
Podemos ver que ele chama a função is_le() (usada para comparar felts, fonte):
// Retorna 1 se a <= b (ou mais precisamente 0 <= b - a < RANGE_CHECK_BOUND).
// Retorna 0 caso contrário.
@known_ap_change
func is_le{range_check_ptr}(a, b) -> felt {
return is_nn(b - a);
}
// ...
// Retorna 1 se a >= 0 (ou mais precisamente 0 <= a < RANGE_CHECK_BOUND).
// Retorna 0 caso contrário.
@known_ap_change
func is_nn{range_check_ptr}(a) -> felt {
%{ memory[ap] = 0 if 0 <= (ids.a % PRIME) < range_check_builtin.bound else 1 %}
jmp out_of_range if [ap] != 0, ap++;
[range_check_ptr] = a;
ap += 20;
let range_check_ptr = range_check_ptr + 1;
return 1;
out_of_range:
%{ memory[ap] = 0 if 0 <= ((-ids.a - 1) % PRIME) < range_check_builtin.bound else 1 %}
jmp need_felt_comparison if [ap] != 0, ap++;
assert [range_check_ptr] = (-a) - 1;
ap += 17;
let range_check_ptr = range_check_ptr + 1;
return 0;
need_felt_comparison:
assert_le_felt(RC_BOUND, a);
return 0;
}
Vou lhe poupar o trabalho de entender como is_nn() funciona...o que você precisa saber é que is_le() funciona como descrito — “Retorna 1 se a <= b. Caso contrário, retorna 0.”
Dica — is_le() analisa todos os 252 bits do felt não apenas os primeiros 128!!!
Ok, ok... vamos voltar um pouco atrás
Digamos que temos duas estruturas Uint256 , A e B.
A é:
- A.low= 0
- A.high = 1
- A deve representar 0 + 1 *2¹²⁸ = 2¹²⁸ = 340282366920938463463374607431768211456
B é:
- B.low = 1
- B.high = 1
- B deve representar 1 + 1 *2¹²⁸ = 1+2¹²⁸ = 340282366920938463463374607431768211457
(B é maior que A por 1).
Vamos executar uint256_le(A, B) :
- a.high == b.high é VERDADEIRO
- retornando is_le(a.low + 1, b.low) avalia para retornar is_le(0 + 1, 1)
- is_le(0 + 1, 1) retorna 1 (significando VERDADEIRO)
Então, uint256_le(A, B) é avaliada como VERDADEIRA, exatamente como esperado (já que A < B).
Até aí tudo bem
Mas e se dermos à uint256_le() um A malformado? Especificamente, um A cujo 129º bit de felt baixo foi ativado.
Neste cenário, A_malformed é:
- A_malformed.low = 2¹²⁹
- A_malformed.high = 1
- A_malformed deve representar 0 + 1*2 ¹²⁸ = 2¹²⁸ = 340282366920938463463374607431768211456
B é:
- B.low = 1
- B.high = 1
- B deve representar 1 + 1*2 ¹²⁸ = 1+2¹²⁸ = 340282366920938463463374607431768211457
Executar a uint256_le(A_malformed, B) deve produzir o mesmo resultado que uint256_le(A, B) (os bits indesejados em A_malformed devem ser ignorados).
Porém, na realidade:
- a.high == b.high é VERDADEIRO
- retornando is_le(a.low + 1, b.low) avalia para retornar is_le(2¹²⁹ + 1, 1)
- is_le(2¹²⁹ + 1, 1) retorna 0 (significando FALSO)
Não é o resultado que esperávamos…
É claro que o mesmo truque (escrever nos bits indesejados para estragar as comparações Uint256) pode ser obtido escrevendo nos bits indesejados do felt alto.
Então a equipe Starkware realmente superou isso… Vamos ver como podemos explorá-lo.
Exploração
A função uint256_le() não é a única vulnerável para as entradas malformadas. Aqui está uma lista parcial de outras funções vulneráveis:
uint256_add()
- não produzirá saída malformada
- a saída pode ser feita para ser muito maior do que deveria ser
uint256_mul()
- a saída pode ser feita para ser muito maior do que deveria ser
- pode produzir saída malformada também
uint256_sub()
- como uint256_add()
uint256_lt()
- o resultado pode ser escolhido de qualquer maneira via entrada malformada (como mostramos acima)
uint256_eq()
- func pode ser feito para retornar FALSO mesmo que as entradas sejam iguais
Os mais sábios entre vocês já podem imaginar como isso pode ser usado para “$$$explorar$$$”. Mas se você quiser testar suas habilidades, confira o desafio cairo-auction do teste 2022 Paradigm CTF. Pode ser resolvido usando seu conhecimento recém-adquirido.
Mitigação e vulnerabilidades no ambiente selvagem
Prevenir vulnerabilidades Uint256 malformadas é bastante simples. Simplesmente chame a função uint256_check() fornecida em todas as entradas Uint256 e você ficará bem.
Os desenvolvedores da Cairo realmente fazem isso? Na maioria das vezes não.
Não vimos a função uint256_check() chamada em entradas em 100% dos contratos que fomos encarregados de auditar até agora. Em alguns deles, isso levou a vulnerabilidades de gravidade média (não divulgaremos os nomes dos projetos, pois alguns deles ainda estão sendo corrigidos).
E eu não culparia os desenvolvedores por isso. Este problema da Uint256 malformada não pode ser encontrado em nenhum lugar nos documentos da Cairo.
De olho no futuro
Felizmente para nós e para a equipe Starkware, a Cairo 1.0 corrige tudo isso. Na Cairo 1.0, Uint256 torna-se um tipo nativo:
(Suponho que isso signifique que a verificação da Uint256 malformado será incorporada, mas nunca se sabe…)
Artigo escrito por Gershon Ballas e traduzido por Marcelo Panegali
Latest comments (0)