Skip to content

Commit

Permalink
feat: solve ethernaut ctf lvl 18
Browse files Browse the repository at this point in the history
  • Loading branch information
leovct committed Sep 25, 2024
1 parent e365d69 commit 9484af7
Show file tree
Hide file tree
Showing 3 changed files with 107 additions and 2 deletions.
2 changes: 1 addition & 1 deletion doc/EthernautCTF.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
| 15 | [NaughtCoin](../src/EthernautCTF/NaughtCoin.sol) || [NaughtCoinExploit](../test/EthernautCTF/NaughtCoinExploit.t.sol) | Use the ERC20 `allowance` and `transferFrom` methods to send tokens on behalf of a nother address. |
| 16 | [Preservation](../src/EthernautCTF/Preservation.sol) (\*) || [PreservationExploit](../test/EthernautCTF/PreservationExploit.t.sol) | Make use of the `delegatecall` to overwrite the storage of the main contract. This time it involved a bit more creativity as it required to overwrite an address (20 bytes) using a uint256 (32 bytes). |
| 17 | [Recovery](../src/EthernautCTF/Recovery.sol) || [RecoveryExploit](../test/EthernautCTF/RecoveryExploit.t.sol) | The address of an Ethereum contract is deterministically computed from the address of its creator (sender) and its nonce (how many transactions the creator has sent). The sender and nonce are RLP-encoded and then hashed with keccak256. For a Solidity implementation, check the exploit code. |
| 18 | [MagicNumber](../src/EthernautCTF/MagicNumber.sol) | | | |
| 18 | [MagicNumber](../src/EthernautCTF/MagicNumber.sol) | | [MagicNumberExploit](../test/EthernautCTF/MagicNumberExploit.t.sol) | - Use raw bytecode to create the smallest possible contract.<br>- Learn about initialization code to be able to run any runtime code.<br>- Learn about `create` to create a contract from the initialization code. |
| 19 | AlienCode || | |
| 20 | Denial || | |
| 21 | [Shop](../src/EthernautCTF/Shop.sol) || | |
Expand Down
2 changes: 1 addition & 1 deletion src/EthernautCTF/MagicNumber.sol
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract MagicNum {
contract MagicNumber {
address public solver;

constructor() {}
Expand Down
105 changes: 105 additions & 0 deletions test/EthernautCTF/MagicNumberExploit.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;

import '../../src/EthernautCTF/MagicNumber.sol';
import '@forge-std/Test.sol';
import '@forge-std/console2.sol';

interface Solver {
function whatIsTheMeaningOfLife() external pure returns (bytes32);
}

// The problem with this naive approach is that the deployed bytecode weights 119 bytes.
// The goal is to have a contract that weights at most 10 bytes!
// [93083] MagicNumberExploit::testNaiveExploit()
// ├─ [23875] → new NaiveSolver@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
// │ └─ ← [Return] 119 bytes of code
// ├─ [0] console::log("Naive solver deployed") [staticcall]
contract NaiveSolver is Solver {
function whatIsTheMeaningOfLife() public pure returns (bytes32) {
return bytes32(uint256(42));
}
}

contract MagicNumberExploit is Test {
MagicNumber target;
address deployer = makeAddr('deployer');

function setUp() public {
vm.startPrank(deployer);
target = new MagicNumber();
console2.log('Target contract deployed');
vm.stopPrank();
}

function testNaiveExploit() public {
Solver solver = new NaiveSolver();
console2.log('Naive solver deployed');

target.setSolver(address(solver));
bytes32 result = Solver(target.solver()).whatIsTheMeaningOfLife();
console2.log(
'What is the meaning of life? %s',
vm.parseUint(vm.toString(result))
);
}

function testSmartExploit() public {
// The size of the contract will be equal to its runtime size with is 10 bytes.
// Indeed, we use 10 opcodes to return the number 42.
//
// Runtime code which returns 42.
// 602a60005260206000F3
// 1. Store 42 to memory at offset 0.
// PUSH1 0x2a (42 in hexadecimal)
// PUSH1 0x00
// MSTORE
// 2. Return 32 bytes from memory, starting at offset 0.
// PUSH1 0x20 (32 in hexadecimal)
// PUSH1 0x00
// RETURN

// Creation code which returns the runtime code.
// 69602a60005260206000F3600052600a6016F3
// 1. Store runtime code to memory at offset 0.
// PUSH10 0x602a60005260206000F3
// PUSH1 0x00
// MSTORE
// 2. Return 10 bytes from memory, starting at offset 22.
// PUSH1 0x0a (10 in hexadecimal)
// PUSH1 0x16 (22 in hexadecimal)
// RETURN
bytes memory bytecode = hex'69602a60005260206000F3600052600a6016F3';

address solver;
assembly {
// Create a new contract using the initialization code provided at the indicated offset in the memory.
// create(value, offset, size)
// - value: the amount of ether (in wei) to send to the new contract.
// - offset: the memory location where the contract's initialization code begins.
// - size: the size of the initialization code in bytes.

// How to compute the offset of the bytecode?
// To compute the offset for the contract bytcode, we use add(bytecode, 0x20) to skip the first
// 32 bytes (0x20 in hexadecimal) which contain the length of the byte array.
// Indeed, when declaring a dynamic-sized array, including the bytes types in Solidity, it is
// stored in memory with a specific layout:
// 1. The first 32 bytes (0x20 in decimal) contain the length of the byte array.
// 2. The actual bytes data starts immeditately after these 32 bytes.

// How to compute the size of the bytecode?
// The creation code, 69602a60005260206000F3600052600a6016F3, is composed of 19 opcodes (or bytes).
// And the hexadecimal value of 19 is 0x13, thus size will be 0x13.
solver := create(0, add(bytecode, 0x20), 0x13)
}
assertNotEq(solver, address(0x0));
console2.log('Smart solver deployed');

target.setSolver(solver);
bytes32 result = Solver(target.solver()).whatIsTheMeaningOfLife();
console2.log(
'What is the meaning of life? %s',
vm.parseUint(vm.toString(result))
);
}
}

0 comments on commit 9484af7

Please sign in to comment.