WEB3DEV

Cover image for Como invadir (quase) qualquer contrato inteligente da Cairo Starknet
Panegali
Panegali

Posted on

Como invadir (quase) qualquer contrato inteligente da Cairo Starknet

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ã:

twitte

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,
}
Enter fullscreen mode Exit fullscreen mode

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),);

}
Enter fullscreen mode Exit fullscreen mode

Podemos ver que ele chama a função is_le() (usada para comparar felts, fonte):

// Retorna 1 se a &lt;= b (ou mais precisamente 0 &lt;= b - a &lt; 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 &lt;= a &lt; RANGE_CHECK_BOUND).

// Retorna 0 caso contrário.

@known_ap_change

func is_nn{range_check_ptr}(a) -> felt {

    %{ memory[ap] = 0 if 0 &lt;= (ids.a % PRIME) &lt; 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 &lt;= ((-ids.a - 1) % PRIME) &lt; 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;

}
Enter fullscreen mode Exit fullscreen mode

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) :

  1. a.high == b.high é VERDADEIRO
  2. retornando is_le(a.low + 1, b.low) avalia para retornar is_le(0 + 1, 1)
  3. 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:

  1. a.high == b.high é VERDADEIRO
  2. retornando is_le(a.low + 1, b.low) avalia para retornar is_le(2¹²⁹ + 1, 1)
  3. 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)