Play to Earn (⭐️⭐️, 68 solves, 100pt)
During setup, a COIN
contract is created where you can deposit/withdraw some ETHs into/from the contract according to the balanceOf
and allowance
records. Initially, 20 ether (20 * 10**18) ETHs are deposited into the COIN
contract, and later 19 ether are transferred to the zero address 0x00
by the arcadeMachine
, namely balanceOf[0x00] = 19 ether
.
To solve this challenge, the player must to call register
and have more than 13.37 ether ETHs.
Note that a permit
function exists where the owner
can allow the spender
to spend the value
amount of the owner
’s balance before the deadline
by providing a valid signature.
|
|
Since the majority of the balance belongs to 0x00, it occurs to me that there should be some way that we can get an allowance from 0x00.
The vital check in the permit
function is that the signature signer
must be equal to owner
.
If we want to get an allowance from 0x00, the owner
should be set to 0x00, so how can the signer
, which is the return value of ecrecover(h, v, r, s)
, also be set to 0x00?
A quick look into the Solidity documentation gives me the answer:
The built-in ecrecover
function returns 0x00 on error.
Thus, what we need to do is just set owner
to 0x00 and provide a signature destined to fail.
Steps to attack:
- Call the
register
function ofSetup
contract so theplayer
address is not 0x00 anymore. - Call the
permit
function ofCoin
contract by providing a valid signature and get a max allowance from 0x00. - Call the
transferFrom
function ofCoin
contract to transfer the 19 ether balance from 0x00 to ourplayer
address. - Call the
withdraw
function ofCoin
contract to extract 19 ETH to ourplayer
address.
|
|
The flag is SEKAI{0wn3r:wh3r3_4r3_mY_c01n5_:<}
ZOO (⭐️⭐️⭐️⭐️⭐️, 13 solves, 326pt)
TLDR:
DEL_ANIMAL enables us to shift one memory slot forward and the animal_index
field of the 0th animal will lay out at the 7th pointer of the array. So we can set the 7th pointer at our will. EDIT_TYPE enables us to write where the pointer points to.
To modify the 0xa0 memory slot from 0x31b to 0x323:
- ADD_ANIMAL(…)
- DEL_ANIMAL(0x00)
- EDIT_TYPE(7, 0x323)
This bypasses the whenNotPaused
modifier check.
EDIT_NAME enables us to write beyond the boundary to change the animal_index
field of the next animal struct.
To set one animal_index
field to be 5fd43c02f6abee0f86a44e719df2622bbeba666f1abf777702c51962ae225299
so that the calculated animal_addr
can be 0:
- EDIT_NAME(0, 32bytes + hex"5fd4…5299")
This sets the animal_index
field of the second animal to be 0x5fd4...5299
, thus resulting in SSTORE(1,1)
which sets isSolved
to be true.
A Zoo
contract is deployed and we are required to set its bool isSolved
storage slot to be true
.
forge inspect src/ZOO.sol:ZOO storageLayout
command gives us the inspection of the Zoo
contract storage layout:
|
|
Since the Zoo
contract inherits from the Pausable
contract which has one bool _pause
storage variable, the bool isSolved
storage variable is located at slot 1
.
During setup, the Zoo
contract is initialized with plenty of animal objects that are stored in the animals
array, and the _pause
flag is set to be true meaning that the status is paused
and the later whenNotPaused
modifier will revert the execution.
Two functions are provided in the Zoo
contract:
function commit(bytes memory data) internal whenNotPaused
can only be called internally if the_pause
flag is set to false.fallback() external payable
is for outside calling.
So the entry point should be the fallback
function.
Both the commit
and fallback
functions are implemented using low-level Solidity Inline Assembly
.
Some prerequisites for EVM opcodes and EVM memory layout are needed to understand what these 2 functions are doing.
To mention a few, the memory layout is aligned to 0x20-byte slots. The first 4 slots 0x00~0x80 are reserved for:
- 0x00~0x40: store values for hash function
- 0x40~0x60: point to the offset where there’s free memory
- 0x60~0x80: zero slot
The 0x40~0x60 memory slot (free memory pointer) is vital here to understand the assembly codes for being utilized several times to allocate free memory slots.
The free memory that can be used during execution starts from the 0x80~0xa0 slot and then grows larger.
The deployed bytecode of the ZOO
contract can be acquired as follows.
|
|
Paste this bytecode into https://bytegraph.xyz/ gives us a visual CFG view:
One can also debug the execution step by step on the EVM playground .
In the disassembled opcodes, at the beginning is the logic for the dispatcher where the first 4 bytes of calldata are extracted and compared with different known 4-byte function signatures. If any known function signature matches, the execution will jump to the corresponding position. And if no match is found, the execution fall backs to the default snippet, namely the fallback
function.
Since the entry point in this challenge can only be the fallback
function, we will first focus on it.
Now, let’s go through the fallback
function snippet by snippet.
|
|
From the view of Solidity source code, we can see that firstly an array of size 1 where each entry is a function pointer is initialized. Then, the array is populated with one element that points to the offset where the commit
function starts.
From the view of the disassembled opcodes, the fallback
function is located at offset 0x003e and the relevant part of these two lines of source code is shown as below.
If we execute the opcodes step by step, we can see that some memory slots are written.
The function array functions
are located at memory[0x80:0xc0] (2 slots), where the first slot is for the length of the array (1) and the remaining array elements/slots (here only 1 left) are initialized with an offset 0x456. The interesting thing here is that the default initialization value for the function pointer is 0x456 (points to the start of the panic
function) instead of 0x0, which helps the execution to stop gracefully if a call is made to an uninitialized function pointer.
Later, the first element in the array is assigned an offset 0x31b, which is the start point of the commit
function, corresponding to the source code functions[0] = commit
.
So now the memory layout is allocated as below:
Flow the path where the function pointer is used, we can find a internal call at the end of the fallback
function.
|
|
Flow the execution flow of the disassembled opcodes, we arrive at a JUMP at offset 0x291.
We can set a breakpoint at the offset 0x291, run, and continue execution until hitting the breakpoint.
Now the top stack is exactly the offset 0x31b, meaning that the JUMP
opcode will jump to the start point of the commit
function (at offset 0x31b) and continue execution there, and this is how the internal call is made.
Step back several opcodes, we can find that the offset 0x31b is loaded from mem[0xa0, 0xc0]
.
Here is the key point.
If we somehow can modify the value stored at mem[0xa0, 0xc0]
, then we can arbitrarily control where the execution will jump to.
Back to the setup procedure of the ZOO
contract, we know that the status is set to pause and any call to the commit
function will be reverted by the modifier whenNotPaused
.
Since there’s no sstore
opcode in the fallback
function, the only one is located at the end of the commit
function, we have no way to change the 0 slot _paused
from false to true. To bypass the whenNotPaused
modifier, we need to find some way to modify mem[0xa0, 0xc0]
and jump past the modifier.
The source code of the whenNotPaused
is shown as below:
|
|
Move on to the opcodes of the commit
function. Two JUMPDEST 0x323 and 0x431 are pushed into the stack. The execution first jumps to 0x431 where is the logic of the whenNotPaused
modifier. It loads the 0 slot _paused
and checks whether it is zero or not, if zero then continues to execute the logic defined in the commit
function body at the right branch, otherwise revert at the left branch.
Therefore, to bypass the modifier check, we need to modify the JUMPDEST to 0x323. Only in this way, we won’t be reverted by the modifier and can execute the logic of the commit
function where we maybe can do something to solve this challenge.
The goal is to change the value at mem[0xa0, 0xc0]
to 0x323.
Move on to the assembly block of the fallback
function.
|
|
From the assembly code above, we can read that calldata
are loaded into the memory. The calldatasize
occupies the first slot, then is the calldata
.
If the length of calldata
is <=20 bytes long, the memory layout will be:
Next, a piece of memory used for the local_animals
is allocated:
|
|
The memory layout becomes:
If we read more about how the local_animals
variable is used, we’ll learn that it’s an array of size 8 where each element is a pointer to the actual Animal(uint256 animal_index, uint256 name_length, string name)
struct. In the memory layout, the first slot is for the counter of how many animals are stored and the next 8 slots are for the pointers to the actual Animal struct.
Later, the calldata
stored in memory is read byte by byte and parsed as three ops:
- ADD_ANIMAL (0x10)
- EDIT_ANIMAL (0x20)
- DEL_ANIMAL (0x30)
|
|
For the ADD_ANIMAL
operation, several operation parameters are extracted from the following calldata
. Then a temp
variable is allocated to point to the next free memory, and an Animal
struct is stored there. At last, the offset to the temp
memory is stored at the corresponding pointer slot, and the animals_count
increments 1.
The operation parameters are:
op (0x10) | idx (<=7) | name_length | animal_index | name |
---|---|---|---|---|
1 byte | 1 byte | 2 bytes | 2 bytes | name_length bytes |
Illustrated in Python:
|
|
If we add an animal add_animal(0, 0x1234, bytes.fromhex("DEADBEAF"))
by sending to the contract with calldata 0x100000041234deadbeaf
, the memory layout becomes:
Note that the last field
name
of theAnimal
struct is variable-length and may occupy more than 1 slot.
For the EDIT_ANIMAL
operation, it supports two fields to modify:
- EDIT_NAME (0x21): modify the third field (
name
) - EDIT_TYPE (0x22): modify the second slot (
name_length
)
The operation parameters are:
op (0x20) | idx (<=7) | EDIT_TYPE (0x21) | name_length | Name |
---|---|---|---|---|
1 byte | 1 byte | 1 byte | 2 bytes | name_length bytes |
op (0x20) | idx (<=7) | EDIT_TYPE (0x22) | new_type |
---|---|---|---|
1 byte | 1 byte | 1 byte | 2 bytes |
Illustrated in Python:
|
|
Note that when modifying the
name
field, the code does not check the name_length and may copy more than 1 slot to the third slot, which is a memory overflow that can be exploited by us.It does not update the seconds
name_length
field to the newname_length
either.
If we add one animal and modify its name
to 0xFF
*0x40 with calldata 0x100000041234deadbeaf2000210040ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
, the memory layout becomes:
For the DEL_ANIMAL
operation, it will remove one pointer slot from the local_animals
array.
The operation parameters are:
op (0x30) | idx (<=7) |
---|---|
1 byte | 1 byte |
Illustrated in Python:
|
|
The way it removes the specified idx
pointer slot is by moving the next (8 - idx
) slots one slot forward.
Here, it moves more than 1 slot than expected, which allows us to set the 7th pointer to any value we want.
If we add 1 animal and delete that with calldata 0x100000041234deadbeaf3000
, the memory layout becomes:
Note that the 7th pointer has been changed to the animal_index
field of the 0th animal.
The key point here is that if we are enabled to control the pointer, we can use EDIT_TYPE
to write to any memory slot we want.
Say we want to achieve the goal of modifying mem[0xa0:0xc0]
to 0x323, we can set the 7th pointer to be 0xa0-0x20=0x80
and use EDIT_TYPE
, which will edit the second slot of where the pointer points to, to set that memory slot to be 0x323.
|
|
With calldata 0x100000040080deadbeaf30002007220323
, the memory layout becomes:
Thus, we have successfully modified the mem[0xa0:0xc0]
to 0x323, bypassing the whenNotPaused
identifier check and can continue the logic defined in the commit
function.
Have a look at the commit
function, we can see that at the end there’s a sstore
opcode. This is our only way to set slot 1 isSolved
to be true.
|
|
The slot to be set is add(add(slot_hash, mul(2, idx)), 1)
, and our isSolved
slot is 1. So add(slot_hash, mul(2, idx))
needs to be 0, which means that slot_hash + 2*idx
should be 0.
The slot_hash
is deterministically calculated by keccak256(animals.slot)
. From the storageLayout, we learn that the animals
storage variable is located in slot 2. So the slot_hash
can be calculated beforehand and the should-be idx
can also be calculated.
|
|
If we somehow can set the idx
to be 0x5fd43c02f6abee0f86a44e719df2622bbeba666f1abf777702c51962ae225299
, then add(slot_hash, mul(2, idx))
will be 0 and the sstore
opcode will store the value at the slot 1.
Note that two variables animal_addr
and animal_counter
are loaded from slot 0 (_paused
) and slot 1 (isSolved
) respectively, both of which are 0. And the several calls made later will fail and return (but not revert). Finally, the value to be stored at the slot 1 will be add(animal_counter, 1)
, which is exactly 1/true.
The commit
function reads every pointer in the local_animals
array and tries to do something with that Animal
struct. The idx
variable is the first field of the Animal
struct. Though the animal_index
is only 2 bytes long when we add an animal, we can exploit the name
memory overflow vulnerability to set the idx
of the next Animal
struct to any value we want, i.e. the magic number 0x5fd4...5299
.
Another method to set the
idx
to the magic number exists where theanimal_counts
field is exploited. Since every time we add anAnimal
struct to some pointer theanimal_counts
field will increment 1, we can repeat adding animals to one pointer and increment theanimal_counts
field to be more than 7. In the loop of thecommit
function, it will read over from the 8 pointers. If theanimal_counts
increment to 9, it will read the first field of the 0th animal as a pointer. So we can set up the memory layout and make theidx
value to be that magic number.
The code to exploit the memory overflow is shown below:
|
|
Send this calldata to the contract, and set 3 breakpoints at:
- 0x35e (animal_addr :=
SLOAD(...)
) - 0x368 (animal_counter :=
SLOAD(...)
) - 0x3e3 (
SSTORE(...)
)
Yes! We have successfully set the isSolved
slot to be true and solved this challenge.
The flag is: SEKAI{super-duper-memory-master-:3}
SURVIVE (⭐️⭐️⭐️⭐️, 5 solves, 423pt)
During setup, a ERC-4337 Abstract Account system is created and we are required to have more than 19 ether balance to win.
Some prerequisites for the Abstract Account are required to understand this challenge.
Learning resources:
- The official EIP-4337 draft: https://eips.ethereum.org/EIPS/eip-4337
- Introduction to Abstract Account by Alchemy:
- Decoding line by line by Bioconomy:
To be short, an abstract account is an on-chain contract that manages assets. Users can send their ops/txs off-chain to the bundlers and the bundlers will help to send those on-chain. However, the bundlers pay the transaction gas fee. To compensate the bundlers, an Entrypoint
contract is used to relay transactions between bundlers and accounts, charge gas fees from the accounts or paymasters, and give back to the beneficiary (bundlers). Paymasters are contracts that will pay gas fees on behalf of the accounts.
In this challenge, we, the _challenger
, have a paymaster that initially deposits 20 ether ETHs to the Entrypoint
contract. We can just withdraw that 20 ETHs from the Entrypoint
contact by calling paymaster.withdrawTo
.
|
|
Run forge test -vv
and the testWithdraw
test function will pass.
However, as _challenger
, we don’t have any ETH to send this transaction on-chain. We must find some way to get some ETHs to the _challenger
address.
What we can do is send our ops to the server, and the server will handle our ops.
|
|
|
|
|
|
From the source code above, we can see that after getting the encoded
(calldata) from us, the server will replace the beneficiary
slot to 0xcafE
, and then send a transaction to entrypoint
to call the handleOps
function with our calldata.
Here, if our ops get validated and executed, the gas fee of this execution will be sent to the beneficiary
.
The calldata after the first 4-byte function signature looks like this:
The regex pattern to replace the beneficiary is (?<=400{24}).{40}
, which matches 40
followed by 24 zeros and replaces the following 40 bytes with 0xcafE
.
Actually, this regex replacement is quite easy to bypass.
We can encode some more junk data to make the pointer to be other values instead of 0x40.
|
|
Encoding one more parameter is one way to make the pointer to be 0x60 instead of 0x40. However, the regex pattern may match something else in the calldata. So a safe way to bypass the regex replacement is to encode two more parameters to let the regex replacement happen on the junk data.
|
|
The calldata after the first 4-byte function signature will look like this:
This helps us to bypass the regex replacement and set the beneficiary
address to any address we want. Thus, we can send some ETHs to the _challenger
address by making it the beneficiary
.
Another way to send some ETHs to the
_challenger
address is transferring from an owned account with non-zero balance on mainnet to the_challenger
account in the forked chain. This can be done because the challenge is created by forking from the mainnet and all the states in the mainnet are kept in the forked chain. Keep caution that there’s a chance that the author can replay your transaction on mainnet and steal your money from that transaction, so just send a small amount of ETHs to make sure the_challenger
can successfully make a call.Yet another idea occurred to me during the CTF was that maybe we could send a transaction with gas price set to 0. I read about it a few days ago that users might send a transaction for free in blockchain. To prove the concept, I run
cast send [paymaster address] "withdrawTo(address,uint256)" [_challenger address] 19000000000000000000 --rpc-url ... --private-key ... --gas-price 0
, but it gave me error:ValueError: {'code': -32003, 'message': 'max fee per gas less than block base fee'}
. It turned out that the minimum gas fee a transaction required was more than 0, so we couldn’t send a transaction for free.
Now that the _challenger
has some ETHs, we can send a transaction to call paymaster.withdrawTo
to withdraw 19 ETHs from the Entrypoint
contract.
The local forge test function is implemented as below:
|
|
A lot of efforts are required to construct the
UserOperation
structure.Since the account contract hasn’t been created yet, when first calling
handleOps
, we need to provide theinitCode
to create the account contract, otherwise thehandleOps
function will revert. What theinitCode
does is call thecreateAccount(address,uint256)
function of theaccountFactory
contract.The signature should be signed correctly as well.
Run forge test -vv
, and then the calldata will be printed out and the test will pass as well.
Sending the calldata to the server solves this challenge.
The flag is: SEKAI{(*V*)/E_1t5_n4t1v3_70k3n5!!!}